+ */
+ public static function excludedPathCases(): iterable
+ {
+ yield 'live api root' => ['/api/live', true];
+ yield 'live api child' => ['/api/live/status', true];
+ yield 'live api sibling' => ['/api/live-status', false];
+ yield 'assets child' => ['/assets/app.css', true];
+ yield 'assets sibling' => ['/assets-preview', false];
+ yield 'build child' => ['/build/app.js', true];
+ yield 'build sibling' => ['/builder', false];
+ yield 'favicon' => ['/favicon.ico', true];
+ yield 'touch icon' => ['/apple-touch-icon.png', true];
+ yield 'well-known security' => ['/.well-known/security.txt', true];
+ yield 'profiler root' => ['/_profiler', true];
+ yield 'profiler child' => ['/_profiler/123', true];
+ yield 'profiler sibling' => ['/_profilerfoo', false];
+ yield 'toolbar child' => ['/_wdt/123', true];
+ yield 'toolbar sibling' => ['/_wdtfoo', false];
+ }
+
+ #[DataProvider('excludedPathCases')]
+ public function testExcludedPathUsesSegmentBoundaries(string $path, bool $excluded): void
+ {
+ $subscriber = (new ReflectionClass(RateLimitRequestSubscriber::class))->newInstanceWithoutConstructor();
+ $paths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'paths');
+ $paths->setValue($subscriber, new PathScopeMatcher());
+ $ignorablePaths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'ignorablePaths');
+ $ignorablePaths->setValue($subscriber, new IgnorableRequestPathMatcher());
+ $method = new \ReflectionMethod(RateLimitRequestSubscriber::class, 'excludedRequest');
+
+ self::assertSame($excluded, $method->invoke($subscriber, Request::create($path)));
+ }
+
+ public function testExcludedRequestDoesNotUseLocalizedTechnicalPathSegments(): void
+ {
+ $subscriber = (new ReflectionClass(RateLimitRequestSubscriber::class))->newInstanceWithoutConstructor();
+ $paths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'paths');
+ $paths->setValue($subscriber, new PathScopeMatcher());
+ $ignorablePaths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'ignorablePaths');
+ $ignorablePaths->setValue($subscriber, new IgnorableRequestPathMatcher());
+ $method = new \ReflectionMethod(RateLimitRequestSubscriber::class, 'excludedRequest');
+ $localized = Request::create('/de/api/live/status');
+ $localized->attributes->set('_locale', 'de');
+
+ self::assertFalse($method->invoke($subscriber, $localized));
+ self::assertFalse($method->invoke($subscriber, Request::create('/de/api/live/status')));
+ }
+
+ public function testProbePriorityRunsBeforeResponseProducingGates(): void
+ {
+ $events = RateLimitRequestSubscriber::getSubscribedEvents()[KernelEvents::REQUEST];
+
+ self::assertSame(['onKernelRequestProbe', 4096], $events[0]);
+ self::assertGreaterThan(1024, $events[0][1]);
+ self::assertGreaterThan(768, $events[0][1]);
+ self::assertGreaterThan(512, $events[0][1]);
+ self::assertGreaterThan(256, $events[0][1]);
+ }
+
+ public function testProbeHookSkipsFullEnforcerForNonProbePaths(): void
+ {
+ $enforcer = (new ReflectionClass(RateLimitEnforcer::class))->newInstanceWithoutConstructor();
+ $responses = (new ReflectionClass(RateLimitResponseRenderer::class))->newInstanceWithoutConstructor();
+ $subscriber = new RateLimitRequestSubscriber(
+ $enforcer,
+ $responses,
+ 'prod',
+ new SetupCompletionMarker(),
+ dirname(__DIR__, 3),
+ new SuspiciousProbePathMatcher(patterns: SuspiciousProbePathMatcher::DEFAULT_PATTERNS),
+ );
+ $event = new RequestEvent(
+ new RateLimitRequestSubscriberTestKernel(),
+ Request::create('/home'),
+ HttpKernelInterface::MAIN_REQUEST,
+ );
+
+ $subscriber->onKernelRequestProbe($event);
+
+ self::assertFalse($event->hasResponse());
+ }
+
+ public function testProbeHookSkipsConsumptionWhenActiveAutoBanAlreadyMatched(): void
+ {
+ $enforcer = (new ReflectionClass(RateLimitEnforcer::class))->newInstanceWithoutConstructor();
+ $responses = (new ReflectionClass(RateLimitResponseRenderer::class))->newInstanceWithoutConstructor();
+ $subscriber = new RateLimitRequestSubscriber(
+ $enforcer,
+ $responses,
+ 'prod',
+ new SetupCompletionMarker(),
+ dirname(__DIR__, 3),
+ new SuspiciousProbePathMatcher(patterns: SuspiciousProbePathMatcher::DEFAULT_PATTERNS),
+ );
+ $request = Request::create('/.env');
+ $request->attributes->set(AutoBanRequestSubscriber::PROBE_RATE_LIMIT_SKIP_ATTRIBUTE, true);
+ $event = new RequestEvent(
+ new RateLimitRequestSubscriberTestKernel(),
+ $request,
+ HttpKernelInterface::MAIN_REQUEST,
+ );
+
+ $subscriber->onKernelRequestProbe($event);
+
+ self::assertFalse($event->hasResponse());
+ }
+
+ public function testProbeHookUsesBareResponseBeforeSetupCompletion(): void
+ {
+ unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]);
+ putenv(SetupCompletionMarker::KEY);
+ $subscriber = $this->subscriberWithRealEnforcer();
+ $event = new RequestEvent(
+ new RateLimitRequestSubscriberTestKernel(),
+ Request::create('/.env'),
+ HttpKernelInterface::MAIN_REQUEST,
+ );
+
+ $subscriber->onKernelRequestProbe($event);
+
+ self::assertTrue($event->hasResponse());
+ self::assertSame(Response::HTTP_BAD_REQUEST, $event->getResponse()->getStatusCode());
+ self::assertStringContainsString('400 - Bad Request', (string) $event->getResponse()->getContent());
+ self::assertStringContainsString('Invalid Request', (string) $event->getResponse()->getContent());
+ self::assertStringContainsString('Request-ID:', (string) $event->getResponse()->getContent());
+ self::assertStringContainsString('no-store', (string) $event->getResponse()->headers->get('Cache-Control'));
+ }
+
+ public function testProbeHookUsesForcedBareResponseAfterSetupCompletion(): void
+ {
+ $subscriber = $this->subscriberWithRealEnforcer();
+ $event = new RequestEvent(
+ new RateLimitRequestSubscriberTestKernel(),
+ Request::create('/.env'),
+ HttpKernelInterface::MAIN_REQUEST,
+ );
+
+ $subscriber->onKernelRequestProbe($event);
+
+ self::assertTrue($event->hasResponse());
+ self::assertSame(Response::HTTP_BAD_REQUEST, $event->getResponse()->getStatusCode());
+ self::assertStringContainsString('400 - Bad Request', (string) $event->getResponse()->getContent());
+ self::assertStringContainsString('Invalid Request', (string) $event->getResponse()->getContent());
+ self::assertStringContainsString('no-store', (string) $event->getResponse()->headers->get('Cache-Control'));
+ }
+
+ public function testOrdinaryHookSkipsSetupWizardBeforeSetupCompletion(): void
+ {
+ unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]);
+ putenv(SetupCompletionMarker::KEY);
+ $subscriber = $this->subscriberWithUninitializedEnforcer();
+ $event = new RequestEvent(
+ new RateLimitRequestSubscriberTestKernel(),
+ Request::create('/setup/database', 'POST', ['_setup_action' => 'test_database']),
+ HttpKernelInterface::MAIN_REQUEST,
+ );
+
+ $subscriber->onKernelRequestOrdinary($event);
+
+ self::assertFalse($event->hasResponse());
+ }
+
+ public function testSetupApplyRequestIsNotSkippedBeforeSetupCompletion(): void
+ {
+ $subscriber = (new ReflectionClass(RateLimitRequestSubscriber::class))->newInstanceWithoutConstructor();
+ $paths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'paths');
+ $paths->setValue($subscriber, new PathScopeMatcher());
+ $method = new \ReflectionMethod(RateLimitRequestSubscriber::class, 'setupApplyRequest');
+
+ self::assertTrue($method->invoke($subscriber, Request::create('/setup/review', 'POST', [
+ '_setup_action' => 'apply',
+ ])));
+ self::assertFalse($method->invoke($subscriber, Request::create('/setup/database', 'POST', [
+ '_setup_action' => 'test_database',
+ ])));
+ self::assertFalse($method->invoke($subscriber, Request::create('/setup/review/extra', 'POST', [
+ '_setup_action' => 'apply',
+ ])));
+ self::assertFalse($method->invoke($subscriber, Request::create('/setup/review', 'GET', [
+ '_setup_action' => 'apply',
+ ])));
+ }
+
+ public function testSetupApplyBeforeSetupCompletionUsesBareTooManyRequestsResponse(): void
+ {
+ unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]);
+ putenv(SetupCompletionMarker::KEY);
+ $subscriber = $this->subscriberWithRealEnforcer();
+ $event = null;
+
+ for ($i = 0; $i < 6; ++$i) {
+ $event = new RequestEvent(
+ new RateLimitRequestSubscriberTestKernel(),
+ Request::create('/setup/review', 'POST', ['_setup_action' => 'apply'], server: [
+ 'REMOTE_ADDR' => '203.0.113.54',
+ 'HTTP_USER_AGENT' => 'SetupApplyLimiterTest',
+ ]),
+ HttpKernelInterface::MAIN_REQUEST,
+ );
+
+ $subscriber->onKernelRequestOrdinary($event);
+ }
+
+ self::assertNotNull($event);
+ self::assertTrue($event->hasResponse());
+ self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $event->getResponse()->getStatusCode());
+ self::assertStringContainsString('429 - Too Many Requests', (string) $event->getResponse()->getContent());
+ self::assertStringContainsString('retry-after:', (string) $event->getResponse()->getContent());
+ self::assertStringContainsString('Request-ID:', (string) $event->getResponse()->getContent());
+ self::assertStringContainsString('no-store', (string) $event->getResponse()->headers->get('Cache-Control'));
+ self::assertNotNull($event->getResponse()->headers->get('Retry-After'));
+ }
+
+ private function subscriberWithUninitializedEnforcer(): RateLimitRequestSubscriber
+ {
+ return new RateLimitRequestSubscriber(
+ (new ReflectionClass(RateLimitEnforcer::class))->newInstanceWithoutConstructor(),
+ $this->responseRenderer(),
+ 'prod',
+ new SetupCompletionMarker(),
+ dirname(__DIR__, 3),
+ new SuspiciousProbePathMatcher(patterns: SuspiciousProbePathMatcher::DEFAULT_PATTERNS),
+ );
+ }
+
+ private function subscriberWithRealEnforcer(): RateLimitRequestSubscriber
+ {
+ $inspector = new AbuseRequestInspector(
+ new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'),
+ new RequestIntentClassifier(),
+ new ActionCostCatalogue(),
+ );
+ $enforcer = new RateLimitEnforcer(
+ $inspector,
+ new Config($this->connection()),
+ new RateLimitPolicyCatalogue(),
+ new RateLimitSubjectSelector(),
+ new RateLimitLimiterFactory(new ArrayAdapter()),
+ new class implements MessageReporterInterface {
+ public function report(Message $message, array $context = []): Message
+ {
+ return $message;
+ }
+
+ public function reportBatch(iterable $records): array
+ {
+ $messages = [];
+ foreach ($records as $record) {
+ $messages[] = $record['message'];
+ }
+
+ return $messages;
+ }
+ },
+ );
+
+ return new RateLimitRequestSubscriber(
+ $enforcer,
+ $this->responseRenderer(),
+ 'prod',
+ new SetupCompletionMarker(),
+ dirname(__DIR__, 3),
+ new SuspiciousProbePathMatcher(patterns: SuspiciousProbePathMatcher::DEFAULT_PATTERNS),
+ );
+ }
+
+ private function responseRenderer(): RateLimitResponseRenderer
+ {
+ return new RateLimitResponseRenderer(
+ new HttpErrorRenderer(
+ (new ReflectionClass(Environment::class))->newInstanceWithoutConstructor(),
+ (new ReflectionClass(PublishedContentResolver::class))->newInstanceWithoutConstructor(),
+ (new ReflectionClass(ContentFieldsetRenderer::class))->newInstanceWithoutConstructor(),
+ (new ReflectionClass(Security::class))->newInstanceWithoutConstructor(),
+ new SetupCompletionMarker(),
+ new AccessRequestMetadata(),
+ dirname(__DIR__, 3),
+ 'test',
+ ),
+ (new ReflectionClass(ApiResponder::class))->newInstanceWithoutConstructor(),
+ new AccessRequestMetadata(),
+ );
+ }
+
+ private function connection(): Connection
+ {
+ $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
+ $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)');
+
+ return $connection;
+ }
+}
+
+final class RateLimitRequestSubscriberTestKernel implements HttpKernelInterface
+{
+ public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response
+ {
+ return new Response();
+ }
+}
diff --git a/tests/Security/RateLimit/RateLimitResetServiceTest.php b/tests/Security/RateLimit/RateLimitResetServiceTest.php
new file mode 100644
index 00000000..992d02c2
--- /dev/null
+++ b/tests/Security/RateLimit/RateLimitResetServiceTest.php
@@ -0,0 +1,243 @@
+services();
+ $request = $this->request('/user/login', 'POST');
+
+ for ($i = 0; $i < 5; ++$i) {
+ self::assertTrue($enforcer->check($request)->isAllowed());
+ }
+
+ self::assertFalse($enforcer->check($request)->isAllowed());
+ self::assertTrue($resets->resetLoginAttempts($request));
+ self::assertTrue($enforcer->check($request)->isAllowed());
+ }
+
+ public function testLoginSuccessResetClearsSubmittedAccountLoginAttempts(): void
+ {
+ [$enforcer, $resets] = $this->services();
+
+ for ($i = 0; $i < 5; ++$i) {
+ self::assertTrue($enforcer->check($this->request('/user/login', 'POST', [
+ 'username' => 'shared-admin',
+ 'password' => 'wrong',
+ ], [
+ 'REMOTE_ADDR' => '203.0.113.'.(20 + $i),
+ ]))->isAllowed());
+ }
+
+ self::assertFalse($enforcer->check($this->request('/user/login', 'POST', [
+ 'username' => 'shared-admin',
+ 'password' => 'wrong',
+ ], [
+ 'REMOTE_ADDR' => '203.0.113.90',
+ ]))->isAllowed());
+
+ self::assertTrue($resets->resetLoginAttempts($this->request('/user/login', 'POST', [
+ 'username' => 'shared-admin',
+ 'password' => 'correct',
+ ], [
+ 'REMOTE_ADDR' => '203.0.113.91',
+ ])));
+
+ self::assertTrue($enforcer->check($this->request('/user/login', 'POST', [
+ 'username' => 'shared-admin',
+ 'password' => 'wrong',
+ ], [
+ 'REMOTE_ADDR' => '203.0.113.92',
+ ]))->isAllowed());
+ }
+
+ public function testLoginSuccessResetUsesActiveProfileDescriptor(): void
+ {
+ $config = new Config($this->connection());
+ $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Strict->value, ConfigValueType::String);
+ [$enforcer, $resets] = $this->services(config: $config);
+ $request = $this->request('/user/login', 'POST');
+
+ self::assertTrue($enforcer->check($request)->isAllowed());
+ self::assertTrue($enforcer->check($request)->isAllowed());
+ self::assertFalse($enforcer->check($request)->isAllowed());
+ self::assertTrue($resets->resetLoginAttempts($request));
+ self::assertTrue($enforcer->check($request)->isAllowed());
+ }
+
+ public function testCaptchaResetRequiresVerifiedProviderBackedSuccess(): void
+ {
+ [, $resets] = $this->services();
+ $request = $this->request('/captcha/submit', 'POST');
+
+ self::assertFalse($resets->resetVerifiedCaptchaFailure($request, 'none', true));
+ self::assertFalse($resets->resetVerifiedCaptchaFailure($request, 'turnstile', false));
+ self::assertTrue($resets->resetVerifiedCaptchaFailure($request, 'turnstile', true));
+ }
+
+ public function testCaptchaResetUsesActiveProfileDescriptor(): void
+ {
+ $config = new Config($this->connection());
+ $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String);
+ $cache = new ArrayAdapter();
+ $factory = new RateLimitLimiterFactory($cache);
+ [, $resets] = $this->services(config: $config, factory: $factory);
+ $request = $this->request('/captcha/submit', 'POST');
+ $catalogue = new RateLimitPolicyCatalogue();
+ $descriptor = $catalogue->descriptor('captcha.failure', RateLimitProfile::Panic);
+ self::assertNotNull($descriptor);
+ $inspector = $this->inspector();
+ $selector = new RateLimitSubjectSelector();
+ $subjectKeys = $selector->subjectKeys($descriptor, $inspector->inspect($request)['subjects']);
+ self::assertNotSame([], $subjectKeys);
+
+ self::assertTrue($factory->consume($descriptor, $subjectKeys[0], 1));
+ self::assertTrue($factory->consume($descriptor, $subjectKeys[0], 1));
+ self::assertInstanceOf(\DateTimeImmutable::class, $factory->consume($descriptor, $subjectKeys[0], 1));
+ self::assertTrue($resets->resetVerifiedCaptchaFailure($request, 'turnstile', true));
+ self::assertTrue($factory->consume($descriptor, $subjectKeys[0], 1));
+ }
+
+ public function testOffModeDoesNotTouchResetStorage(): void
+ {
+ $config = new Config($this->connection());
+ $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Off->value, ConfigValueType::String);
+ [, $resets] = $this->services(config: $config, factory: new RateLimitLimiterFactory(new ResetFailingCachePool()));
+
+ self::assertFalse($resets->resetLoginAttempts($this->request('/user/login', 'POST')));
+ self::assertFalse($resets->resetVerifiedCaptchaFailure($this->request('/captcha/submit', 'POST'), 'turnstile', true));
+ }
+
+ public function testResetFailureReportsThroughMessageLayer(): void
+ {
+ $messages = new RecordingRateLimitMessageReporter();
+ [, $resets] = $this->services(factory: new RateLimitLimiterFactory(new ResetFailingCachePool()), messages: $messages);
+
+ self::assertFalse($resets->resetVerifiedCaptchaFailure($this->request('/captcha/submit', 'POST'), 'turnstile', true));
+ self::assertSame(SecurityMessageCode::RATE_LIMIT_RESET_DEGRADED, $messages->records[0]['message']->code());
+ self::assertSame('security.rate_limit.reset', $messages->records[0]['context']['operation']);
+ }
+
+ /**
+ * @return array{0: RateLimitEnforcer, 1: RateLimitResetService}
+ */
+ private function services(?Config $config = null, ?RateLimitLimiterFactory $factory = null, ?RecordingRateLimitMessageReporter $messages = null): array
+ {
+ $inspector = $this->inspector();
+ $catalogue = new RateLimitPolicyCatalogue();
+ $selector = new RateLimitSubjectSelector();
+ $factory ??= new RateLimitLimiterFactory(new ArrayAdapter());
+ $config ??= new Config($this->connection());
+ $messages ??= new RecordingRateLimitMessageReporter();
+
+ return [
+ new RateLimitEnforcer($inspector, $config, $catalogue, $selector, $factory, $messages),
+ new RateLimitResetService($inspector, $config, $catalogue, $selector, $factory, $messages),
+ ];
+ }
+
+ private function inspector(): AbuseRequestInspector
+ {
+ return new AbuseRequestInspector(
+ new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'),
+ new RequestIntentClassifier(),
+ new ActionCostCatalogue(),
+ );
+ }
+
+ /**
+ * @param array $parameters
+ * @param array $server
+ */
+ private function request(string $path, string $method, array $parameters = [], array $server = []): Request
+ {
+ return Request::create($path, $method, $parameters, server: [
+ 'REMOTE_ADDR' => '203.0.113.50',
+ 'HTTP_USER_AGENT' => 'RateLimitResetServiceTest',
+ ...$server,
+ ]);
+ }
+
+ private function connection(): Connection
+ {
+ $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
+ $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)');
+
+ return $connection;
+ }
+}
+
+final class ResetFailingCachePool implements CacheItemPoolInterface
+{
+ public function getItem(string $key): CacheItemInterface
+ {
+ throw new \RuntimeException('rate limiter storage unavailable');
+ }
+
+ public function getItems(array $keys = []): iterable
+ {
+ throw new \RuntimeException('rate limiter storage unavailable');
+ }
+
+ public function hasItem(string $key): bool
+ {
+ throw new \RuntimeException('rate limiter storage unavailable');
+ }
+
+ public function clear(): bool
+ {
+ throw new \RuntimeException('rate limiter storage unavailable');
+ }
+
+ public function deleteItem(string $key): bool
+ {
+ throw new \RuntimeException('rate limiter storage unavailable');
+ }
+
+ public function deleteItems(array $keys): bool
+ {
+ throw new \RuntimeException('rate limiter storage unavailable');
+ }
+
+ public function save(CacheItemInterface $item): bool
+ {
+ throw new \RuntimeException('rate limiter storage unavailable');
+ }
+
+ public function saveDeferred(CacheItemInterface $item): bool
+ {
+ throw new \RuntimeException('rate limiter storage unavailable');
+ }
+
+ public function commit(): bool
+ {
+ throw new \RuntimeException('rate limiter storage unavailable');
+ }
+}
diff --git a/tests/Security/RateLimit/RateLimitResponseRendererTest.php b/tests/Security/RateLimit/RateLimitResponseRendererTest.php
new file mode 100644
index 00000000..1747003b
--- /dev/null
+++ b/tests/Security/RateLimit/RateLimitResponseRendererTest.php
@@ -0,0 +1,51 @@
+
+ */
+ public static function jsonSurfaceCases(): iterable
+ {
+ yield 'api v1' => ['/api/v1/status', true];
+ yield 'cron root' => ['/cron', true];
+ yield 'cron child' => ['/cron/run', true];
+ yield 'cron lookalike content' => ['/cronjobs', false];
+ yield 'browser content' => ['/docs', false];
+ }
+
+ #[DataProvider('jsonSurfaceCases')]
+ public function testJsonSurfaceUsesPathBoundaries(string $path, bool $json): void
+ {
+ $renderer = (new ReflectionClass(RateLimitResponseRenderer::class))->newInstanceWithoutConstructor();
+ $paths = new \ReflectionProperty(RateLimitResponseRenderer::class, 'paths');
+ $paths->setValue($renderer, new PathScopeMatcher());
+ $method = new \ReflectionMethod(RateLimitResponseRenderer::class, 'jsonSurface');
+
+ self::assertSame($json, $method->invoke($renderer, Request::create($path)));
+ }
+
+ public function testJsonSurfaceDoesNotUseLocalizedTechnicalPathSegments(): void
+ {
+ $renderer = (new ReflectionClass(RateLimitResponseRenderer::class))->newInstanceWithoutConstructor();
+ $paths = new \ReflectionProperty(RateLimitResponseRenderer::class, 'paths');
+ $paths->setValue($renderer, new PathScopeMatcher());
+ $method = new \ReflectionMethod(RateLimitResponseRenderer::class, 'jsonSurface');
+ $localized = Request::create('/de/cron/run');
+ $localized->attributes->set('_locale', 'de');
+
+ self::assertFalse($method->invoke($renderer, $localized));
+ self::assertFalse($method->invoke($renderer, Request::create('/de/cron/run')));
+ }
+}
diff --git a/tests/Security/RateLimit/RecordingRateLimitMessageReporter.php b/tests/Security/RateLimit/RecordingRateLimitMessageReporter.php
new file mode 100644
index 00000000..fd7b477e
--- /dev/null
+++ b/tests/Security/RateLimit/RecordingRateLimitMessageReporter.php
@@ -0,0 +1,37 @@
+}>
+ */
+ public array $records = [];
+
+ public function report(Message $message, array $context = []): Message
+ {
+ $this->records[] = [
+ 'message' => $message,
+ 'context' => $context,
+ ];
+
+ return $message;
+ }
+
+ public function reportBatch(iterable $records): array
+ {
+ $messages = [];
+
+ foreach ($records as $record) {
+ $messages[] = $this->report($record['message'], $record['context'] ?? []);
+ }
+
+ return $messages;
+ }
+}
diff --git a/tests/Security/SessionVisitorBindingSubscriberTest.php b/tests/Security/SessionVisitorBindingSubscriberTest.php
index c01ad736..fba12de2 100644
--- a/tests/Security/SessionVisitorBindingSubscriberTest.php
+++ b/tests/Security/SessionVisitorBindingSubscriberTest.php
@@ -5,10 +5,20 @@
namespace App\Tests\Security;
use App\Core\Access\AccessActor;
+use App\Core\Log\AccessRequestMetadata;
use App\Core\Log\AuditLoggerInterface;
+use App\Core\Log\DatabaseLogRetentionPolicy;
use App\Core\Statistics\VisitorIdGenerator;
use App\Entity\UserAccount;
+use App\Security\Abuse\AbuseRequestInspector;
+use App\Security\Abuse\AbuseSubjectResolver;
+use App\Security\Abuse\ActionCostCatalogue;
+use App\Security\Abuse\RequestIntentClassifier;
+use App\Security\Abuse\SecuritySignalRecorder;
+use App\Security\AutoBan\AutoBanRequestSubscriber;
use App\Security\SessionVisitorBindingSubscriber;
+use Doctrine\DBAL\Connection;
+use Doctrine\DBAL\DriverManager;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -81,6 +91,91 @@ public function testItTerminatesSessionsWhenTheBoundVisitorChanges(): void
self::assertSame($currentVisitorId, $auditLogger->records[0]['context']['current_visitor_id']);
}
+ public function testItRecordsSecuritySignalWhenTheBoundVisitorChanges(): void
+ {
+ $tokenStorage = new TokenStorage();
+ $user = $this->user();
+ $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles()));
+ $auditLogger = new RecordingSessionAuditLogger();
+ $generator = new VisitorIdGenerator('test-secret');
+ $request = Request::create('/admin', server: ['REMOTE_ADDR' => '203.0.113.42']);
+ $request->attributes->set(AccessRequestMetadata::REQUEST_ID_ATTRIBUTE, 'request-session-mismatch');
+ $session = new Session(new MockArraySessionStorage());
+ $session->set(SessionVisitorBindingSubscriber::SESSION_VISITOR_ID, 'previousVisitorId1234');
+ $request->setSession($session);
+ $connection = $this->signalConnection();
+
+ $event = new RequestEvent(new SessionBindingTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST);
+
+ (new SessionVisitorBindingSubscriber(
+ $tokenStorage,
+ $generator,
+ $auditLogger,
+ new AbuseRequestInspector(
+ new AbuseSubjectResolver($generator, $tokenStorage, 'test-secret'),
+ new RequestIntentClassifier(),
+ new ActionCostCatalogue(),
+ ),
+ new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)),
+ new AccessRequestMetadata(),
+ ))->onKernelRequest($event);
+
+ $row = $connection->fetchAssociative('SELECT * FROM security_signal_event');
+ self::assertIsArray($row);
+ self::assertSame('session', $row['signal_type']);
+ self::assertSame('security.signal.session_visitor_mismatch', $row['reason_code']);
+ self::assertSame('ERROR', $row['severity']);
+ self::assertSame(90, (int) $row['confidence']);
+ self::assertSame('user', $row['subject_type']);
+ self::assertSame($user->uid(), $row['subject_identifier']);
+ self::assertSame('request-session-mismatch', $row['request_id']);
+ self::assertSame($generator->generate($request), $row['visitor_id']);
+
+ $context = json_decode((string) $row['context'], true, flags: JSON_THROW_ON_ERROR);
+ self::assertSame('previousVisitorId1234', $context['previous_visitor_id']);
+ self::assertSame($generator->generate($request), $context['current_visitor_id']);
+ self::assertSame(1, $context['change_count']);
+ self::assertIsString($context['ip_bucket']);
+ self::assertNotSame('', $context['ip_bucket']);
+ }
+
+ public function testItDoesNotOverrideAutoBanResponsesOrRecordSignals(): void
+ {
+ $tokenStorage = new TokenStorage();
+ $user = $this->user();
+ $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles()));
+ $auditLogger = new RecordingSessionAuditLogger();
+ $generator = new VisitorIdGenerator('test-secret');
+ $request = Request::create('/admin', server: ['REMOTE_ADDR' => '203.0.113.42']);
+ $request->attributes->set(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE, true);
+ $session = new Session(new MockArraySessionStorage());
+ $session->set(SessionVisitorBindingSubscriber::SESSION_VISITOR_ID, 'previousVisitorId1234');
+ $request->setSession($session);
+ $connection = $this->signalConnection();
+ $event = new RequestEvent(new SessionBindingTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST);
+ $event->setResponse(new Response('blocked', Response::HTTP_FORBIDDEN));
+
+ (new SessionVisitorBindingSubscriber(
+ $tokenStorage,
+ $generator,
+ $auditLogger,
+ new AbuseRequestInspector(
+ new AbuseSubjectResolver($generator, $tokenStorage, 'test-secret'),
+ new RequestIntentClassifier(),
+ new ActionCostCatalogue(),
+ ),
+ new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)),
+ new AccessRequestMetadata(),
+ ))->onKernelRequest($event);
+
+ self::assertSame(Response::HTTP_FORBIDDEN, $event->getResponse()?->getStatusCode());
+ self::assertSame('blocked', $event->getResponse()?->getContent());
+ self::assertNotNull($tokenStorage->getToken());
+ self::assertSame('previousVisitorId1234', $session->get(SessionVisitorBindingSubscriber::SESSION_VISITOR_ID));
+ self::assertSame([], $auditLogger->records);
+ self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event'));
+ }
+
public function testItKeepsSessionsWhenTheBoundVisitorMatches(): void
{
$tokenStorage = new TokenStorage();
@@ -119,6 +214,15 @@ private function user(): UserAccount
'hash',
);
}
+
+ private function signalConnection(): Connection
+ {
+ $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
+ $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)');
+ $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)');
+
+ return $connection;
+ }
}
final class RecordingSessionAuditLogger implements AuditLoggerInterface
diff --git a/tests/Setup/SetupDefaultSeedTest.php b/tests/Setup/SetupDefaultSeedTest.php
index 13566de4..0d71f25e 100644
--- a/tests/Setup/SetupDefaultSeedTest.php
+++ b/tests/Setup/SetupDefaultSeedTest.php
@@ -5,11 +5,17 @@
namespace App\Tests\Setup;
use App\Api\ApiFeaturePolicy;
+use App\Core\AdminAcl\AdminFeatureDefaults;
+use App\Core\AdminAcl\AdminFeatureOverrideStore;
use App\Core\Config\ConfigDefaultProviderInterface;
+use App\Core\Geo\MaxMindGeoIpConfig;
+use App\Core\Log\DatabaseLogRetentionPolicy;
use App\Setup\DatabaseDriver;
use App\Setup\SetupDefaultSeed;
use App\Setup\SetupInput;
use App\Scheduler\SchedulerSettings;
+use App\Security\Abuse\SuspiciousProbePathMatcher;
+use App\Security\AutoBan\AutoBanPolicy;
use App\Security\UserFlowConfig;
use PHPUnit\Framework\TestCase;
@@ -29,6 +35,17 @@ public function testItBuildsInputAwareConfigDefaults(): void
self::assertTrue($settings[ApiFeaturePolicy::ENABLED_KEY]);
self::assertFalse($settings[ApiFeaturePolicy::CORS_ENABLED_KEY]);
self::assertSame([], $settings[ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY]);
+ self::assertFalse($settings[MaxMindGeoIpConfig::ENABLED_KEY]);
+ self::assertSame(MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, $settings[MaxMindGeoIpConfig::DATABASE_PATH_KEY]);
+ self::assertSame('', $settings[MaxMindGeoIpConfig::LICENSE_KEY_KEY]);
+ self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, $settings[DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY]);
+ self::assertSame(DatabaseLogRetentionPolicy::defaultSecuritySignalRetentionDays(), $settings[DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY]);
+ self::assertSame(SuspiciousProbePathMatcher::defaultPatternText(), $settings[SuspiciousProbePathMatcher::PATTERNS_KEY]);
+ self::assertTrue($settings[AutoBanPolicy::ENABLED_KEY]);
+ self::assertSame(AutoBanPolicy::DEFAULT_TRUSTED_ACCESS_LEVEL, $settings[AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY]);
+ self::assertSame(AutoBanPolicy::DEFAULT_SCORE_THRESHOLD, $settings[AutoBanPolicy::SCORE_THRESHOLD_KEY]);
+ self::assertTrue($settings[AutoBanPolicy::NEW_BAN_OWNER_ALERTS_KEY]);
+ self::assertSame((new AdminFeatureDefaults())->overrides(), $settings[AdminFeatureOverrideStore::CONFIG_KEY]);
}
public function testItUsesCentralConfigDefaultsForSetupSeededSettings(): void
@@ -64,15 +81,28 @@ public function testEverySetupConfigKeyHasACentralDefaultExceptSetupInputValues(
UserFlowConfig::REGISTRATION_MODE_KEY,
\App\Core\Log\ConfigAuditLogPolicy::ENABLED_KEY,
\App\Core\Log\ConfigAuditLogPolicy::EVENTS_KEY,
+ DatabaseLogRetentionPolicy::MESSAGE_LOG_RETENTION_DAYS_KEY,
+ DatabaseLogRetentionPolicy::AUDIT_LOG_RETENTION_DAYS_KEY,
+ DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY,
+ DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY,
+ SuspiciousProbePathMatcher::PATTERNS_KEY,
+ AutoBanPolicy::ENABLED_KEY,
+ AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY,
+ AutoBanPolicy::SCORE_THRESHOLD_KEY,
+ AutoBanPolicy::NEW_BAN_OWNER_ALERTS_KEY,
\App\Core\Statistics\AccessStatisticsPolicy::ENABLED_KEY,
\App\Core\Statistics\AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY,
+ MaxMindGeoIpConfig::ENABLED_KEY,
+ MaxMindGeoIpConfig::DATABASE_PATH_KEY,
+ MaxMindGeoIpConfig::LICENSE_KEY_KEY,
ApiFeaturePolicy::ENABLED_KEY,
ApiFeaturePolicy::CORS_ENABLED_KEY,
ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY,
SchedulerSettings::ENABLED_KEY,
SchedulerSettings::GET_AUTH_ENABLED_KEY,
- SchedulerSettings::PACKAGE_ACTION_QUEUES_ENABLED_KEY,
+ SchedulerSettings::EXTENSION_ACTION_QUEUES_ENABLED_KEY,
SchedulerSettings::WEB_TRIGGER_ENABLED_KEY,
+ AdminFeatureOverrideStore::CONFIG_KEY,
];
$inputOnlyKeys = [
'site.title',
diff --git a/tests/Setup/SetupRunnerTest.php b/tests/Setup/SetupRunnerTest.php
index dc28d08f..e04989f7 100644
--- a/tests/Setup/SetupRunnerTest.php
+++ b/tests/Setup/SetupRunnerTest.php
@@ -7,6 +7,7 @@
use App\Core\ActionLog\ActionLog;
use App\Core\Asset\AssetMessageCode;
use App\Core\Asset\AssetMessageKey;
+use App\Core\Geo\MaxMindGeoIpConfig;
use App\Core\Process\PhpCliBinaryPreferenceStore;
use App\Core\Process\PhpCliBinaryValidator;
use App\Database\DatabaseReadyState;
@@ -113,7 +114,7 @@ public function testItRunsSetupAndSeedsConfigurationAndAdmin(): void
['composer', 'dump-env', 'test'],
[PHP_BINARY, $this->root.'/bin/console', 'doctrine:migrations:migrate', '--no-interaction', '--env=test'],
[PHP_BINARY, $this->root.'/bin/console', 'cache:clear', '--env=test'],
- [PHP_BINARY, $this->root.'/bin/console', 'packages:discover', '--run-now', '--trigger=setup', '--env=test'],
+ [PHP_BINARY, $this->root.'/bin/console', 'extensions:discover', '--run-now', '--trigger=setup', '--env=test'],
[PHP_BINARY, $this->root.'/bin/console', 'assets:rebuild', '--trigger=setup', '--env=test', '--json'],
[PHP_BINARY, $this->root.'/bin/console', 'mercure:stop', '--env=test'],
[PHP_BINARY, $this->root.'/bin/console', 'mercure:health', '--env=test'],
@@ -121,6 +122,10 @@ public function testItRunsSetupAndSeedsConfigurationAndAdmin(): void
$pdo = new PDO('sqlite:'.$databasePath);
$configRows = $pdo->query('SELECT config_key, value FROM config_entry')->fetchAll(PDO::FETCH_KEY_PAIR);
+ $geoIpLicenseSensitive = $pdo->query(sprintf(
+ "SELECT sensitive FROM config_entry WHERE config_key = '%s'",
+ MaxMindGeoIpConfig::LICENSE_KEY_KEY,
+ ))->fetchColumn();
$aclGroups = $pdo->query('SELECT identifier, min_role FROM acl_group WHERE json_extract(metadata, "$.seeded_by") = "setup" ORDER BY min_role')->fetchAll(PDO::FETCH_ASSOC);
$adminUser = $pdo->query("SELECT password_hash, role FROM user_account WHERE username = 'admin'")->fetch(PDO::FETCH_ASSOC);
$stateMarkers = $pdo->query("SELECT marker_key, marker_value FROM state_marker WHERE subject_type = 'user_account' ORDER BY marker_key")->fetchAll(PDO::FETCH_KEY_PAIR);
@@ -129,6 +134,7 @@ public function testItRunsSetupAndSeedsConfigurationAndAdmin(): void
$homeTitle = $pdo->query(sprintf("SELECT field_content FROM content_field_value WHERE revision_uid = '%s' AND field_identifier = 'title' AND language = '%s'", $seed->homeContentRevision()['uid'], $input->language()))->fetchColumn();
self::assertSame($seed->configMap($input), $this->decodedConfigRows($configRows, array_keys($seed->configMap($input))));
+ self::assertSame(1, (int) $geoIpLicenseSensitive);
self::assertSame(array_map(static fn (array $group): array => [
'identifier' => $group['identifier'],
'min_role' => $group['min_role'],
@@ -512,7 +518,7 @@ public function testItFallsBackToSystemComposerWhenBundledComposerIsUnavailable(
['composer', 'dump-env', 'test'],
[PHP_BINARY, $this->root.'/bin/console', 'doctrine:migrations:migrate', '--no-interaction', '--env=test'],
[PHP_BINARY, $this->root.'/bin/console', 'cache:clear', '--env=test'],
- [PHP_BINARY, $this->root.'/bin/console', 'packages:discover', '--run-now', '--trigger=setup', '--env=test'],
+ [PHP_BINARY, $this->root.'/bin/console', 'extensions:discover', '--run-now', '--trigger=setup', '--env=test'],
[PHP_BINARY, $this->root.'/bin/console', 'assets:rebuild', '--trigger=setup', '--env=test', '--json'],
[PHP_BINARY, $this->root.'/bin/console', 'mercure:stop', '--env=test'],
[PHP_BINARY, $this->root.'/bin/console', 'mercure:health', '--env=test'],
@@ -561,8 +567,8 @@ public function testDryRunReturnsPlannedChangesWithoutWriting(): void
self::assertSame($seed->contentSchema()['identifier'], $entries[6]['context']['schema']);
self::assertSame('clear_cache', $entries[7]['name']);
self::assertSame([PHP_BINARY, $this->root.'/bin/console', 'cache:clear', '--env=test'], $entries[7]['context']['command']);
- self::assertSame('run_package_discovery', $entries[8]['name']);
- self::assertSame([PHP_BINARY, $this->root.'/bin/console', 'packages:discover', '--run-now', '--trigger=setup', '--env=test'], $entries[8]['context']['command']);
+ self::assertSame('run_extension_discovery', $entries[8]['name']);
+ self::assertSame([PHP_BINARY, $this->root.'/bin/console', 'extensions:discover', '--run-now', '--trigger=setup', '--env=test'], $entries[8]['context']['command']);
self::assertSame('run_asset_rebuild', $entries[9]['name']);
self::assertSame([PHP_BINARY, $this->root.'/bin/console', 'assets:rebuild', '--trigger=setup', '--env=test', '--json'], $entries[9]['context']['command']);
self::assertSame('run_mercure_health', $entries[10]['name']);
diff --git a/tests/Support/DatabaseSeed/TestDatabaseExtensionSeeder.php b/tests/Support/DatabaseSeed/TestDatabaseExtensionSeeder.php
index a4ddf318..85ae8eb3 100644
--- a/tests/Support/DatabaseSeed/TestDatabaseExtensionSeeder.php
+++ b/tests/Support/DatabaseSeed/TestDatabaseExtensionSeeder.php
@@ -8,10 +8,10 @@ final class TestDatabaseExtensionSeeder
{
public static function seed(TestDatabaseSeedWriter $writer): void
{
- $writer->insert('extension_package', [
+ $writer->insert('extension', [
'uid' => '00000000-0000-7000-8000-000000000401',
- 'package_scopes' => $writer->json(['frontend-theme', 'backend-theme', 'system-template']),
- 'package_name' => 'system',
+ 'extension_scopes' => $writer->json(['frontend-theme', 'backend-theme', 'system-template']),
+ 'extension_name' => 'system',
'path' => '.',
'manifest_version' => '1',
'installed_version' => '0.1.0-dev',
diff --git a/tests/Support/DatabaseSeed/TestDatabaseSchemaSeeder.php b/tests/Support/DatabaseSeed/TestDatabaseSchemaSeeder.php
index c83ba3cf..ba427e9f 100644
--- a/tests/Support/DatabaseSeed/TestDatabaseSchemaSeeder.php
+++ b/tests/Support/DatabaseSeed/TestDatabaseSchemaSeeder.php
@@ -32,7 +32,7 @@ public static function seed(TestDatabaseSeedWriter $writer): void
'description' => $writer->json($schema['description']),
'definition' => $writer->json($schema['definition']),
'custom_twig' => null,
- 'definition_hash' => hash('sha256', $writer->json($schema['definition'])),
+ 'definition_hash' => self::definitionHash($schema['title'], $schema['description'], $schema['definition'], null),
'use_min_level' => 0,
'use_group_identifiers' => null,
'edit_min_level' => 3,
@@ -103,6 +103,21 @@ private static function articleDefinition(): array
];
}
+ /**
+ * @param array $title
+ * @param array $description
+ * @param array $definition
+ */
+ private static function definitionHash(array $title, array $description, array $definition, ?string $customTwig): string
+ {
+ return hash('sha256', json_encode([
+ 'title' => $title,
+ 'description' => $description,
+ 'definition' => $definition,
+ 'custom_twig' => $customTwig,
+ ], JSON_THROW_ON_ERROR));
+ }
+
private function __construct()
{
}
diff --git a/tests/Support/TestSuiteLifecycle.php b/tests/Support/TestSuiteLifecycle.php
index a64976a2..ee54da57 100644
--- a/tests/Support/TestSuiteLifecycle.php
+++ b/tests/Support/TestSuiteLifecycle.php
@@ -22,6 +22,7 @@ public static function initialize(): void
public static function cleanup(): void
{
+ self::stopMercureHub(dirname(__DIR__, 2));
self::removeDirectory(self::temporaryRoot());
}
@@ -133,6 +134,18 @@ private static function runConsoleCommand(string $projectRoot, array $arguments)
}
}
+ private static function stopMercureHub(string $projectRoot): void
+ {
+ try {
+ self::runConsoleCommand($projectRoot, [
+ 'mercure:stop',
+ '--env=test',
+ ]);
+ } catch (RuntimeException) {
+ return;
+ }
+ }
+
private static function removeDirectory(string $directory): void
{
if (!is_dir($directory)) {
diff --git a/tests/View/Alert/MercureUiAlertPublisherTest.php b/tests/View/Alert/MercureUiAlertPublisherTest.php
index a4a8f388..fc40bb9c 100644
--- a/tests/View/Alert/MercureUiAlertPublisherTest.php
+++ b/tests/View/Alert/MercureUiAlertPublisherTest.php
@@ -66,13 +66,13 @@ public function testItTranslatesStructuredMessagesBeforePublishing(): void
$hub = new RecordingHub();
$publisher = $this->publisher($hub);
- $publisher->publishToSession('session-id', Message::success('message.package.discovery_completed', ['%package%' => 'Demo']));
+ $publisher->publishToSession('session-id', Message::success('message.extension.discovery_completed', ['%extension%' => 'Demo']));
$payload = json_decode($hub->update?->getData() ?? '{}', true, 512, JSON_THROW_ON_ERROR);
- self::assertSame('message.package.discovery_completed', $payload['message']);
+ self::assertSame('message.extension.discovery_completed', $payload['message']);
self::assertSame('success', $payload['level']);
self::assertSame(CommonMessageCode::SUCCESS, $payload['code']);
- self::assertSame('message.package.discovery_completed', $payload['translation_key']);
+ self::assertSame('message.extension.discovery_completed', $payload['translation_key']);
self::assertArrayNotHasKey('context', $payload);
}
diff --git a/tests/View/Alert/UiAlertTest.php b/tests/View/Alert/UiAlertTest.php
index 920cd454..60c7b59d 100644
--- a/tests/View/Alert/UiAlertTest.php
+++ b/tests/View/Alert/UiAlertTest.php
@@ -56,21 +56,21 @@ public function testItCanAttachStableDedupeId(): void
public function testItDoesNotSerializeDiagnosticContext(): void
{
$alert = UiAlert::translated(
- 'Package failed.',
+ 'Extension failed.',
'error',
- 'package.runtime.failure',
- 'message.package.runtime_failure',
+ 'extension.runtime.failure',
+ 'message.extension.runtime_failure',
['path' => '/srv/example/private.log', 'exception' => 'RuntimeException'],
);
self::assertArrayNotHasKey('context', $alert->toArray());
- self::assertSame('message.package.runtime_failure', $alert->toArray()['translation_key']);
+ self::assertSame('message.extension.runtime_failure', $alert->toArray()['translation_key']);
}
public function testPresentationFiltersUnsafeActionLinks(): void
{
$alert = UiAlert::fromLevel('info', 'Saved')->withPresentation(UiAlertPresentation::persistent(actions: [
- UiAlertAction::link('Open', '/admin/packages', '_blank'),
+ UiAlertAction::link('Open', '/admin/extensions', '_blank'),
UiAlertAction::link('Script', 'javascript:alert(1)'),
['label' => 'Data', 'href' => 'data:text/html,boom'],
['label' => 'Protocol-relative', 'href' => '//evil.example.test/path'],
@@ -80,7 +80,7 @@ public function testPresentationFiltersUnsafeActionLinks(): void
]));
self::assertSame([
- ['label' => 'Open', 'href' => '/admin/packages', 'target' => '_blank'],
+ ['label' => 'Open', 'href' => '/admin/extensions', 'target' => '_blank'],
['label' => 'External', 'href' => 'https://example.test/privacy', 'target' => '_self'],
['label' => 'Event', 'event' => 'operation-overlay:show', 'detail' => ['id' => 'operation-1']],
], $alert->toArray()['actions']);
@@ -89,13 +89,13 @@ public function testPresentationFiltersUnsafeActionLinks(): void
public function testDirectAlertActionsUseTheSameLinkPolicy(): void
{
$alert = UiAlert::fromLevel('info', 'Saved', actions: [
- ['label' => 'Open', 'href' => '/admin/packages'],
+ ['label' => 'Open', 'href' => '/admin/extensions'],
['label' => 'Script', 'href' => 'javascript:alert(1)'],
['label' => 'Event', 'event' => 'operation-overlay:show'],
]);
self::assertSame([
- ['label' => 'Open', 'href' => '/admin/packages'],
+ ['label' => 'Open', 'href' => '/admin/extensions'],
['label' => 'Event', 'event' => 'operation-overlay:show'],
], $alert->toArray()['actions']);
}
diff --git a/tests/View/Alert/WorkflowResultAlertSelectorTest.php b/tests/View/Alert/WorkflowResultAlertSelectorTest.php
index 2941e30f..f097fc1a 100644
--- a/tests/View/Alert/WorkflowResultAlertSelectorTest.php
+++ b/tests/View/Alert/WorkflowResultAlertSelectorTest.php
@@ -17,9 +17,9 @@ public function testItKeepsSuccessSeverityWhenSuccessResultStartsWithDiagnosticM
{
$selector = new WorkflowResultAlertSelector();
$message = Message::debug(
- 'package.dependency.resolved',
- 'message.package.dependency.resolved',
- ['%package%' => 'demo'],
+ 'extension.dependency.resolved',
+ 'message.extension.dependency.resolved',
+ ['%extension%' => 'demo'],
['internal' => true],
);
@@ -27,16 +27,16 @@ public function testItKeepsSuccessSeverityWhenSuccessResultStartsWithDiagnosticM
self::assertSame(MessageLevel::Success, $alert->level());
self::assertSame(CommonMessageCode::SUCCESS, $alert->code());
- self::assertSame('message.package.dependency.resolved', $alert->translationKey());
- self::assertSame(['%package%' => 'demo'], $alert->parameters());
+ self::assertSame('message.extension.dependency.resolved', $alert->translationKey());
+ self::assertSame(['%extension%' => 'demo'], $alert->parameters());
self::assertSame([], $alert->context());
}
public function testItPrefersExplicitSuccessMessages(): void
{
$selector = new WorkflowResultAlertSelector();
- $debug = Message::debug('package.dependency.resolved', 'message.package.dependency.resolved');
- $success = Message::success('message.package.lifecycle.activated', ['%package%' => 'demo']);
+ $debug = Message::debug('extension.dependency.resolved', 'message.extension.dependency.resolved');
+ $success = Message::success('message.extension.lifecycle.activated', ['%extension%' => 'demo']);
$alert = $selector->fromResult(WorkflowResult::success(messages: [$debug, $success]));
@@ -46,7 +46,7 @@ public function testItPrefersExplicitSuccessMessages(): void
public function testItUsesFirstIssueForFailedResults(): void
{
$selector = new WorkflowResultAlertSelector();
- $issue = Message::error('package.lifecycle.not_found', 'message.package.lifecycle.not_found');
+ $issue = Message::error('extension.lifecycle.not_found', 'message.extension.lifecycle.not_found');
$alert = $selector->fromResult(WorkflowResult::failed([$issue]));
diff --git a/tests/View/PackageMacroRegistryTest.php b/tests/View/ExtensionMacroRegistryTest.php
similarity index 63%
rename from tests/View/PackageMacroRegistryTest.php
rename to tests/View/ExtensionMacroRegistryTest.php
index e98b68f8..fc1cd13f 100644
--- a/tests/View/PackageMacroRegistryTest.php
+++ b/tests/View/ExtensionMacroRegistryTest.php
@@ -4,17 +4,17 @@
namespace App\Tests\View;
-use App\View\PackageMacroRegistry;
+use App\View\ExtensionMacroRegistry;
use PHPUnit\Framework\TestCase;
-final class PackageMacroRegistryTest extends TestCase
+final class ExtensionMacroRegistryTest extends TestCase
{
public function testItExposesNamespacedCoreMacroTemplates(): void
{
- $registry = new PackageMacroRegistry();
+ $registry = new ExtensionMacroRegistry();
self::assertSame('@root/macros/core/ui.html.twig', $registry->template('core', 'ui'));
self::assertSame('@root/macros/core/form.html.twig', $registry->template('core', 'form'));
- self::assertNull($registry->template('package', 'missing'));
+ self::assertNull($registry->template('extension', 'missing'));
}
}
diff --git a/tests/View/Http/HttpErrorRendererTest.php b/tests/View/Http/HttpErrorRendererTest.php
new file mode 100644
index 00000000..a6e6a391
--- /dev/null
+++ b/tests/View/Http/HttpErrorRendererTest.php
@@ -0,0 +1,125 @@
+previousServerValue = $_SERVER[SetupCompletionMarker::KEY] ?? null;
+ $this->previousEnvValue = $_ENV[SetupCompletionMarker::KEY] ?? null;
+ $this->previousPutenvValue = getenv(SetupCompletionMarker::KEY);
+ unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]);
+ putenv(SetupCompletionMarker::KEY);
+ }
+
+ protected function tearDown(): void
+ {
+ unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]);
+
+ if (null !== $this->previousServerValue) {
+ $_SERVER[SetupCompletionMarker::KEY] = $this->previousServerValue;
+ }
+
+ if (null !== $this->previousEnvValue) {
+ $_ENV[SetupCompletionMarker::KEY] = $this->previousEnvValue;
+ }
+
+ is_string($this->previousPutenvValue)
+ ? putenv(SetupCompletionMarker::KEY.'='.$this->previousPutenvValue)
+ : putenv(SetupCompletionMarker::KEY);
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function setupBareStatusCases(): iterable
+ {
+ foreach (Response::$statusTexts as $statusCode => $statusText) {
+ if ($statusCode >= 400 && $statusCode < 600) {
+ yield sprintf('%d %s', $statusCode, $statusText) => [$statusCode];
+ }
+ }
+ }
+
+ #[DataProvider('setupBareStatusCases')]
+ public function testItReturnsBareKnownErrorResponsesBeforeSetupCompletion(int $statusCode): void
+ {
+ $response = $this->renderer()->resolve($statusCode, Request::create('/setup/missing'));
+
+ self::assertSame($statusCode, $response->getStatusCode());
+ self::assertStringContainsString(sprintf(
+ '%d - %s',
+ $statusCode,
+ htmlspecialchars(Response::$statusTexts[$statusCode], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
+ ), (string) $response->getContent());
+ self::assertStringContainsString('Request-ID:', (string) $response->getContent());
+ self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control'));
+ self::assertStringContainsString('text/html', (string) $response->headers->get('Content-Type'));
+ }
+
+ public function testBareResponseKeepsAdditionalHeadersAndContext(): void
+ {
+ $request = Request::create('/setup/review');
+ $request->attributes->set('_access_request_id', 'req-test-123');
+ $response = $this->renderer()->bare(Response::HTTP_TOO_MANY_REQUESTS, $request, [
+ 'bare_context' => 'retry-after: 60',
+ ], ['Retry-After' => '60']);
+
+ self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode());
+ self::assertStringContainsString('429 - Too Many Requests', (string) $response->getContent());
+ self::assertStringContainsString('retry-after: 60
', (string) $response->getContent());
+ self::assertStringContainsString('Request-ID: req-test-123
', (string) $response->getContent());
+ self::assertSame('60', $response->headers->get('Retry-After'));
+ self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control'));
+ }
+
+ public function testResolveCanForceBareResponseAfterSetupCompletion(): void
+ {
+ $_SERVER[SetupCompletionMarker::KEY] = '1';
+ $request = Request::create('/blocked');
+ $request->attributes->set(AccessRequestMetadata::REQUEST_ID_ATTRIBUTE, 'req-forced');
+ $response = $this->renderer()->resolve(Response::HTTP_FORBIDDEN, $request, [
+ 'bare_context' => '',
+ ], forceBare: true);
+
+ self::assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode());
+ self::assertStringContainsString('403 - Forbidden', (string) $response->getContent());
+ self::assertStringContainsString('<blocked>
', (string) $response->getContent());
+ self::assertStringContainsString('Request-ID: req-forced
', (string) $response->getContent());
+ self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control'));
+ }
+
+ private function renderer(): HttpErrorRenderer
+ {
+ return new HttpErrorRenderer(
+ (new ReflectionClass(Environment::class))->newInstanceWithoutConstructor(),
+ (new ReflectionClass(PublishedContentResolver::class))->newInstanceWithoutConstructor(),
+ (new ReflectionClass(ContentFieldsetRenderer::class))->newInstanceWithoutConstructor(),
+ (new ReflectionClass(Security::class))->newInstanceWithoutConstructor(),
+ new SetupCompletionMarker(),
+ new AccessRequestMetadata(),
+ dirname(__DIR__, 2),
+ 'test',
+ );
+ }
+}
diff --git a/tests/View/Http/ResponseHookSubscriberTest.php b/tests/View/Http/ResponseHookSubscriberTest.php
index 2860ec12..15f1f503 100644
--- a/tests/View/Http/ResponseHookSubscriberTest.php
+++ b/tests/View/Http/ResponseHookSubscriberTest.php
@@ -42,7 +42,7 @@ public function testItRejectsUnsafeResponseHeaderHookChanges(): void
{
$dispatcher = new EventDispatcher();
$dispatcher->addListener(ResponseHeadersEvent::class, static function (ResponseHeadersEvent $event): void {
- $event->setHeader('Set-Cookie', 'session=package-owned');
+ $event->setHeader('Set-Cookie', 'session=extension-owned');
$event->setHeader('X-Bad-Value', "first\r\nsecond");
$event->setHeader('X-Frame-Options', 'ALLOWALL');
$event->removeHeader('Content-Security-Policy');
@@ -68,7 +68,7 @@ public function testItAppliesHtmlOutputHookChanges(): void
{
$dispatcher = new EventDispatcher();
$dispatcher->addListener(OutputGeneratedEvent::class, static function (OutputGeneratedEvent $event): void {
- $event->appendContent('');
+ $event->appendContent('');
});
$response = new Response('', 200, [
'Content-Type' => 'text/html; charset=UTF-8',
@@ -77,7 +77,7 @@ public function testItAppliesHtmlOutputHookChanges(): void
$this->subscriber($dispatcher)->onKernelResponse($this->responseEvent($response));
- self::assertSame('', $response->getContent());
+ self::assertSame('', $response->getContent());
self::assertFalse($response->headers->has('Content-Length'));
}
diff --git a/tests/View/SystemPackageMetadataProviderTest.php b/tests/View/SystemExtensionMetadataProviderTest.php
similarity index 85%
rename from tests/View/SystemPackageMetadataProviderTest.php
rename to tests/View/SystemExtensionMetadataProviderTest.php
index 710ef4ff..1ec62481 100644
--- a/tests/View/SystemPackageMetadataProviderTest.php
+++ b/tests/View/SystemExtensionMetadataProviderTest.php
@@ -4,16 +4,16 @@
namespace App\Tests\View;
-use App\View\SystemPackageMetadataProvider;
+use App\View\SystemExtensionMetadataProvider;
use PHPUnit\Framework\TestCase;
-final class SystemPackageMetadataProviderTest extends TestCase
+final class SystemExtensionMetadataProviderTest extends TestCase
{
- public function testItExposesSystemPackageMetadataFromRootManifest(): void
+ public function testItExposesSystemExtensionMetadataFromRootManifest(): void
{
$projectDir = dirname(__DIR__, 2);
$manifest = $this->rootManifest($projectDir);
- $metadata = (new SystemPackageMetadataProvider($projectDir))->metadata();
+ $metadata = (new SystemExtensionMetadataProvider($projectDir))->metadata();
self::assertSame('system', $metadata['identifier']);
self::assertSame($manifest['APP_NAME'], $metadata['name']);
diff --git a/tests/View/Template/ExtensionTemplatePathConfiguratorTest.php b/tests/View/Template/ExtensionTemplatePathConfiguratorTest.php
new file mode 100644
index 00000000..95ff596b
--- /dev/null
+++ b/tests/View/Template/ExtensionTemplatePathConfiguratorTest.php
@@ -0,0 +1,216 @@
+root = $this->createTemporaryDirectory('system-template-paths');
+ }
+
+ protected function tearDown(): void
+ {
+ $this->removeDirectory($this->root);
+ }
+
+ public function testItRegistersScopedExtensionPathsOnTwigFilesystemLoader(): void
+ {
+ $this->writeTestFile($this->root, 'templates/frontend/.keep', '');
+ $this->writeTestFile($this->root, 'templates/backend/.keep', '');
+ $this->writeTestFile($this->root, 'templates/.keep', '');
+ $this->writeTestFile($this->root, 'extensions/theme/templates/frontend/.keep', '');
+ $this->writeTestFile($this->root, 'extensions/backend/templates/backend/.keep', '');
+ $this->writeTestFile($this->root, 'extensions/system/templates/.keep', '');
+ $this->writeTestFile($this->root, 'extensions/module/templates/.keep', '');
+ $this->writeTestFile($this->root, 'extensions/module/templates/frontend/.keep', '');
+ $this->writeTestFile($this->root, 'extensions/module/templates/backend/.keep', '');
+ $this->writeTestFile($this->root, 'extensions/captcha/templates/provider/.keep', '');
+ $this->writeTestFile($this->root, 'extensions/editor/templates/provider/.keep', '');
+ $this->writeTestFile($this->root, 'templates/provider/.keep', '');
+
+ $loader = new FilesystemLoader();
+ $twig = new Environment($loader);
+ $configurator = new ExtensionTemplatePathConfigurator(
+ $twig,
+ new StaticExtensionProvider([
+ $this->extension('theme', [ExtensionScope::FrontendTheme]),
+ $this->extension('backend', [ExtensionScope::BackendTheme]),
+ $this->extension('system', [ExtensionScope::SystemTemplate]),
+ $this->extension('module', [ExtensionScope::Module]),
+ $this->extension('captcha', [ExtensionScope::CaptchaProvider]),
+ $this->extension('editor', [ExtensionScope::EditorProvider]),
+ ]),
+ new ExtensionTemplatePathResolver($this->root),
+ );
+
+ $configurator->configure();
+
+ self::assertSame([
+ $this->root.'/extensions/theme/templates/frontend',
+ $this->root.'/templates/frontend',
+ $this->root.'/extensions/module/templates/frontend',
+ ], $loader->getPaths('frontend'));
+ self::assertSame([
+ $this->root.'/extensions/backend/templates/backend',
+ $this->root.'/templates/backend',
+ $this->root.'/extensions/module/templates/backend',
+ ], $loader->getPaths('backend'));
+ self::assertSame([
+ $this->root.'/extensions/system/templates',
+ $this->root.'/templates',
+ $this->root.'/extensions/theme/templates',
+ $this->root.'/extensions/backend/templates',
+ $this->root.'/extensions/module/templates',
+ $this->root.'/extensions/captcha/templates',
+ $this->root.'/extensions/editor/templates',
+ ], $loader->getPaths('root'));
+ self::assertSame([
+ $this->root.'/extensions/captcha/templates/provider',
+ $this->root.'/extensions/editor/templates/provider',
+ $this->root.'/templates/provider',
+ ], $loader->getPaths('provider'));
+ }
+
+ public function testItLetsProviderNamespaceResolveActiveProviderBeforeNativeFallback(): void
+ {
+ $this->writeTestFile($this->root, 'extensions/captcha/templates/provider/captcha/field.html.twig', 'captcha provider');
+ $this->writeTestFile($this->root, 'templates/provider/captcha/field.html.twig', 'captcha native');
+ $this->writeTestFile($this->root, 'templates/provider/editor/richtext.html.twig', 'editor native');
+
+ $loader = new FilesystemLoader();
+ $twig = new Environment($loader);
+ $configurator = new ExtensionTemplatePathConfigurator(
+ $twig,
+ new StaticExtensionProvider([
+ $this->extension('captcha', [ExtensionScope::CaptchaProvider]),
+ ]),
+ new ExtensionTemplatePathResolver($this->root),
+ );
+
+ $configurator->configure();
+
+ self::assertSame('captcha provider', $twig->render('@provider/captcha/field.html.twig'));
+ self::assertSame('editor native', $twig->render('@provider/editor/richtext.html.twig'));
+ }
+
+ public function testItRegistersExtensionPathsBeforeIconConsoleCommands(): void
+ {
+ $this->writeTestFile($this->root, 'templates/.keep', '');
+ $this->writeTestFile($this->root, 'extensions/module/templates/.keep', '');
+
+ $loader = new FilesystemLoader();
+ $twig = new Environment($loader);
+ $configurator = new ExtensionTemplatePathConfigurator(
+ $twig,
+ new StaticExtensionProvider([
+ $this->extension('module', [ExtensionScope::Module]),
+ ]),
+ new ExtensionTemplatePathResolver($this->root),
+ );
+
+ $configurator->onConsoleCommand($this->consoleEvent('ux:icons:lock'));
+
+ self::assertContains($this->root.'/extensions/module/templates', $loader->getPaths('root'));
+ }
+
+ public function testItDoesNotRegisterExtensionPathsForUnrelatedConsoleCommands(): void
+ {
+ $this->writeTestFile($this->root, 'templates/.keep', '');
+ $this->writeTestFile($this->root, 'extensions/module/templates/.keep', '');
+
+ $loader = new FilesystemLoader();
+ $twig = new Environment($loader);
+ $configurator = new ExtensionTemplatePathConfigurator(
+ $twig,
+ new StaticExtensionProvider([
+ $this->extension('module', [ExtensionScope::Module]),
+ ]),
+ new ExtensionTemplatePathResolver($this->root),
+ );
+
+ $configurator->onConsoleCommand($this->consoleEvent('cache:clear'));
+
+ self::assertSame([], $loader->getPaths('root'));
+ }
+
+ /**
+ * @param list $scopes
+ */
+ private function extension(string $name, array $scopes): Extension
+ {
+ return new Extension(
+ $this->uuid(),
+ $scopes,
+ $name,
+ 'extensions/'.$name,
+ );
+ }
+
+ private function uuid(): string
+ {
+ return Uuid::v7()->toRfc4122();
+ }
+
+ private function consoleEvent(string $commandName): ConsoleCommandEvent
+ {
+ return new ConsoleCommandEvent(new Command($commandName), new ArrayInput([]), new NullOutput());
+ }
+}
+
+final readonly class StaticExtensionProvider implements ActiveExtensionProviderInterface
+{
+ /**
+ * @param list $extensions
+ */
+ public function __construct(private array $extensions)
+ {
+ }
+
+ /**
+ * @return list
+ */
+ public function extensions(?ExtensionScope $scope = null): array
+ {
+ if (null === $scope) {
+ return $this->extensions;
+ }
+
+ return array_values(array_filter(
+ $this->extensions,
+ static fn (Extension $extension): bool => $extension->hasScope($scope),
+ ));
+ }
+
+ public function extension(string $extensionName): ?Extension
+ {
+ foreach ($this->extensions as $extension) {
+ if ($extension->extensionName() === $extensionName) {
+ return $extension;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/tests/View/Template/ExtensionTemplatePathResolverTest.php b/tests/View/Template/ExtensionTemplatePathResolverTest.php
new file mode 100644
index 00000000..f8e2cd8b
--- /dev/null
+++ b/tests/View/Template/ExtensionTemplatePathResolverTest.php
@@ -0,0 +1,107 @@
+pathsForNamespace(TemplateNamespace::Frontend, [
+ $this->extension('blog', [ExtensionScope::Module]),
+ $this->extension('captcha', [ExtensionScope::CaptchaProvider]),
+ $this->extension('theme', [ExtensionScope::FrontendTheme]),
+ ]);
+
+ self::assertSame([
+ '/project/extensions/theme/templates/frontend',
+ '/project/templates/frontend',
+ '/project/extensions/blog/templates/frontend',
+ '/project/extensions/captcha/templates/frontend',
+ ], $paths);
+ }
+
+ public function testItOrdersBackendOverridesBeforeNativeFallback(): void
+ {
+ $resolver = new ExtensionTemplatePathResolver('/project');
+ $paths = $resolver->pathsForNamespace(TemplateNamespace::Backend, [
+ $this->extension('module', [ExtensionScope::Module]),
+ $this->extension('editor', [ExtensionScope::EditorProvider]),
+ $this->extension('theme', [ExtensionScope::BackendTheme]),
+ ]);
+
+ self::assertSame([
+ '/project/extensions/theme/templates/backend',
+ '/project/templates/backend',
+ '/project/extensions/module/templates/backend',
+ '/project/extensions/editor/templates/backend',
+ ], $paths);
+ }
+
+ public function testItKeepsRootOverridesBehindSystemTemplateScope(): void
+ {
+ $resolver = new ExtensionTemplatePathResolver('/project');
+ $paths = $resolver->pathsForNamespace('@root', [
+ $this->extension('plain', [ExtensionScope::Module]),
+ $this->extension('system', [ExtensionScope::SystemTemplate]),
+ ]);
+
+ self::assertSame([
+ '/project/extensions/system/templates',
+ '/project/templates',
+ '/project/extensions/plain/templates',
+ ], $paths);
+ }
+
+ public function testItBuildsProviderPathsBeforeNativeFallback(): void
+ {
+ $resolver = new ExtensionTemplatePathResolver('/project');
+ $paths = $resolver->providerPaths([
+ $this->extension('module', [ExtensionScope::Module]),
+ $this->extension('turnstile', [ExtensionScope::CaptchaProvider]),
+ $this->extension('tinymce', [ExtensionScope::EditorProvider]),
+ ]);
+
+ self::assertSame([
+ '/project/extensions/turnstile/templates/provider',
+ '/project/extensions/tinymce/templates/provider',
+ '/project/templates/provider',
+ ], $paths);
+ }
+
+ public function testItRejectsUnknownNamespaces(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('message.view.template_namespace.unsupported');
+
+ (new ExtensionTemplatePathResolver('/project'))->pathsForNamespace('unknown', []);
+ }
+
+ /**
+ * @param list $scopes
+ */
+ private function extension(string $name, array $scopes): Extension
+ {
+ return new Extension(
+ $this->uuid(),
+ $scopes,
+ $name,
+ 'extensions/'.$name,
+ );
+ }
+
+ private function uuid(): string
+ {
+ return Uuid::v7()->toRfc4122();
+ }
+}
diff --git a/tests/View/Template/PackageTemplatePathConfiguratorTest.php b/tests/View/Template/PackageTemplatePathConfiguratorTest.php
deleted file mode 100644
index ee43f041..00000000
--- a/tests/View/Template/PackageTemplatePathConfiguratorTest.php
+++ /dev/null
@@ -1,216 +0,0 @@
-root = $this->createTemporaryDirectory('system-template-paths');
- }
-
- protected function tearDown(): void
- {
- $this->removeDirectory($this->root);
- }
-
- public function testItRegistersScopedPackagePathsOnTwigFilesystemLoader(): void
- {
- $this->writeTestFile($this->root, 'templates/frontend/.keep', '');
- $this->writeTestFile($this->root, 'templates/backend/.keep', '');
- $this->writeTestFile($this->root, 'templates/.keep', '');
- $this->writeTestFile($this->root, 'packages/theme/templates/frontend/.keep', '');
- $this->writeTestFile($this->root, 'packages/backend/templates/backend/.keep', '');
- $this->writeTestFile($this->root, 'packages/system/templates/.keep', '');
- $this->writeTestFile($this->root, 'packages/module/templates/.keep', '');
- $this->writeTestFile($this->root, 'packages/module/templates/frontend/.keep', '');
- $this->writeTestFile($this->root, 'packages/module/templates/backend/.keep', '');
- $this->writeTestFile($this->root, 'packages/captcha/templates/provider/.keep', '');
- $this->writeTestFile($this->root, 'packages/editor/templates/provider/.keep', '');
- $this->writeTestFile($this->root, 'templates/provider/.keep', '');
-
- $loader = new FilesystemLoader();
- $twig = new Environment($loader);
- $configurator = new PackageTemplatePathConfigurator(
- $twig,
- new StaticPackageProvider([
- $this->package('theme', [PackageScope::FrontendTheme]),
- $this->package('backend', [PackageScope::BackendTheme]),
- $this->package('system', [PackageScope::SystemTemplate]),
- $this->package('module', [PackageScope::Module]),
- $this->package('captcha', [PackageScope::CaptchaProvider]),
- $this->package('editor', [PackageScope::EditorProvider]),
- ]),
- new PackageTemplatePathResolver($this->root),
- );
-
- $configurator->configure();
-
- self::assertSame([
- $this->root.'/packages/theme/templates/frontend',
- $this->root.'/templates/frontend',
- $this->root.'/packages/module/templates/frontend',
- ], $loader->getPaths('frontend'));
- self::assertSame([
- $this->root.'/packages/backend/templates/backend',
- $this->root.'/templates/backend',
- $this->root.'/packages/module/templates/backend',
- ], $loader->getPaths('backend'));
- self::assertSame([
- $this->root.'/packages/system/templates',
- $this->root.'/templates',
- $this->root.'/packages/theme/templates',
- $this->root.'/packages/backend/templates',
- $this->root.'/packages/module/templates',
- $this->root.'/packages/captcha/templates',
- $this->root.'/packages/editor/templates',
- ], $loader->getPaths('root'));
- self::assertSame([
- $this->root.'/packages/captcha/templates/provider',
- $this->root.'/packages/editor/templates/provider',
- $this->root.'/templates/provider',
- ], $loader->getPaths('provider'));
- }
-
- public function testItLetsProviderNamespaceResolveActiveProviderBeforeNativeFallback(): void
- {
- $this->writeTestFile($this->root, 'packages/captcha/templates/provider/captcha/field.html.twig', 'captcha provider');
- $this->writeTestFile($this->root, 'templates/provider/captcha/field.html.twig', 'captcha native');
- $this->writeTestFile($this->root, 'templates/provider/editor/richtext.html.twig', 'editor native');
-
- $loader = new FilesystemLoader();
- $twig = new Environment($loader);
- $configurator = new PackageTemplatePathConfigurator(
- $twig,
- new StaticPackageProvider([
- $this->package('captcha', [PackageScope::CaptchaProvider]),
- ]),
- new PackageTemplatePathResolver($this->root),
- );
-
- $configurator->configure();
-
- self::assertSame('captcha provider', $twig->render('@provider/captcha/field.html.twig'));
- self::assertSame('editor native', $twig->render('@provider/editor/richtext.html.twig'));
- }
-
- public function testItRegistersPackagePathsBeforeIconConsoleCommands(): void
- {
- $this->writeTestFile($this->root, 'templates/.keep', '');
- $this->writeTestFile($this->root, 'packages/module/templates/.keep', '');
-
- $loader = new FilesystemLoader();
- $twig = new Environment($loader);
- $configurator = new PackageTemplatePathConfigurator(
- $twig,
- new StaticPackageProvider([
- $this->package('module', [PackageScope::Module]),
- ]),
- new PackageTemplatePathResolver($this->root),
- );
-
- $configurator->onConsoleCommand($this->consoleEvent('ux:icons:lock'));
-
- self::assertContains($this->root.'/packages/module/templates', $loader->getPaths('root'));
- }
-
- public function testItDoesNotRegisterPackagePathsForUnrelatedConsoleCommands(): void
- {
- $this->writeTestFile($this->root, 'templates/.keep', '');
- $this->writeTestFile($this->root, 'packages/module/templates/.keep', '');
-
- $loader = new FilesystemLoader();
- $twig = new Environment($loader);
- $configurator = new PackageTemplatePathConfigurator(
- $twig,
- new StaticPackageProvider([
- $this->package('module', [PackageScope::Module]),
- ]),
- new PackageTemplatePathResolver($this->root),
- );
-
- $configurator->onConsoleCommand($this->consoleEvent('cache:clear'));
-
- self::assertSame([], $loader->getPaths('root'));
- }
-
- /**
- * @param list $scopes
- */
- private function package(string $name, array $scopes): ExtensionPackage
- {
- return new ExtensionPackage(
- $this->uuid(),
- $scopes,
- $name,
- 'packages/'.$name,
- );
- }
-
- private function uuid(): string
- {
- return Uuid::v7()->toRfc4122();
- }
-
- private function consoleEvent(string $commandName): ConsoleCommandEvent
- {
- return new ConsoleCommandEvent(new Command($commandName), new ArrayInput([]), new NullOutput());
- }
-}
-
-final readonly class StaticPackageProvider implements ActivePackageProviderInterface
-{
- /**
- * @param list $packages
- */
- public function __construct(private array $packages)
- {
- }
-
- /**
- * @return list
- */
- public function packages(?PackageScope $scope = null): array
- {
- if (null === $scope) {
- return $this->packages;
- }
-
- return array_values(array_filter(
- $this->packages,
- static fn (ExtensionPackage $package): bool => $package->hasScope($scope),
- ));
- }
-
- public function package(string $packageName): ?ExtensionPackage
- {
- foreach ($this->packages as $package) {
- if ($package->packageName() === $packageName) {
- return $package;
- }
- }
-
- return null;
- }
-}
diff --git a/tests/View/Template/PackageTemplatePathResolverTest.php b/tests/View/Template/PackageTemplatePathResolverTest.php
deleted file mode 100644
index c7f5a6f4..00000000
--- a/tests/View/Template/PackageTemplatePathResolverTest.php
+++ /dev/null
@@ -1,107 +0,0 @@
-pathsForNamespace(TemplateNamespace::Frontend, [
- $this->package('blog', [PackageScope::Module]),
- $this->package('captcha', [PackageScope::CaptchaProvider]),
- $this->package('theme', [PackageScope::FrontendTheme]),
- ]);
-
- self::assertSame([
- '/project/packages/theme/templates/frontend',
- '/project/templates/frontend',
- '/project/packages/blog/templates/frontend',
- '/project/packages/captcha/templates/frontend',
- ], $paths);
- }
-
- public function testItOrdersBackendOverridesBeforeNativeFallback(): void
- {
- $resolver = new PackageTemplatePathResolver('/project');
- $paths = $resolver->pathsForNamespace(TemplateNamespace::Backend, [
- $this->package('module', [PackageScope::Module]),
- $this->package('editor', [PackageScope::EditorProvider]),
- $this->package('theme', [PackageScope::BackendTheme]),
- ]);
-
- self::assertSame([
- '/project/packages/theme/templates/backend',
- '/project/templates/backend',
- '/project/packages/module/templates/backend',
- '/project/packages/editor/templates/backend',
- ], $paths);
- }
-
- public function testItKeepsRootOverridesBehindSystemTemplateScope(): void
- {
- $resolver = new PackageTemplatePathResolver('/project');
- $paths = $resolver->pathsForNamespace('@root', [
- $this->package('plain', [PackageScope::Module]),
- $this->package('system', [PackageScope::SystemTemplate]),
- ]);
-
- self::assertSame([
- '/project/packages/system/templates',
- '/project/templates',
- '/project/packages/plain/templates',
- ], $paths);
- }
-
- public function testItBuildsProviderPathsBeforeNativeFallback(): void
- {
- $resolver = new PackageTemplatePathResolver('/project');
- $paths = $resolver->providerPaths([
- $this->package('module', [PackageScope::Module]),
- $this->package('turnstile', [PackageScope::CaptchaProvider]),
- $this->package('tinymce', [PackageScope::EditorProvider]),
- ]);
-
- self::assertSame([
- '/project/packages/turnstile/templates/provider',
- '/project/packages/tinymce/templates/provider',
- '/project/templates/provider',
- ], $paths);
- }
-
- public function testItRejectsUnknownNamespaces(): void
- {
- $this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('message.view.template_namespace.unsupported');
-
- (new PackageTemplatePathResolver('/project'))->pathsForNamespace('unknown', []);
- }
-
- /**
- * @param list $scopes
- */
- private function package(string $name, array $scopes): ExtensionPackage
- {
- return new ExtensionPackage(
- $this->uuid(),
- $scopes,
- $name,
- 'packages/'.$name,
- );
- }
-
- private function uuid(): string
- {
- return Uuid::v7()->toRfc4122();
- }
-}
diff --git a/tests/View/Twig/ViewTwigExtensionTest.php b/tests/View/Twig/ViewTwigExtensionTest.php
index 07db3cac..28c2146d 100644
--- a/tests/View/Twig/ViewTwigExtensionTest.php
+++ b/tests/View/Twig/ViewTwigExtensionTest.php
@@ -4,6 +4,7 @@
namespace App\Tests\View\Twig;
+use App\Core\Manifest\ManifestParser;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Twig\Environment;
@@ -14,11 +15,12 @@ public function testItExposesViewGlobalsFunctionsAndMarkdownFilter(): void
self::bootKernel();
$twig = self::getContainer()->get(Environment::class);
+ $systemVersion = $this->rootManifestVersion();
$html = $twig->createTemplate(
- '{{ view_context().system_package.name }}|{{ macro_template("core", "ui") }}|{{ event_hooks()|length }}|{{ navigation("main")|length }}|{{ debug_info().hooks is defined ? "debug" : "missing" }}|{{ package_setting("demo-module", "missing.key", "fallback") }}|{{ footer_copyright("backend") }}|{{ "**ok**"|render_markdown }}',
+ '{{ view_context().system_extension.name }}|{{ macro_template("core", "ui") }}|{{ event_hooks()|length }}|{{ navigation("main")|length }}|{{ debug_info().hooks is defined ? "debug" : "missing" }}|{{ extension_setting("demo-module", "missing.key", "fallback") }}|{{ footer_copyright("backend") }}|{{ "**ok**"|render_markdown }}',
)->render();
- self::assertSame('Studio|@root/macros/core/ui.html.twig|11|4|debug|fallback|Powered by [Studio](https://www.aavion.media) 0.2.4|ok
', $html);
+ self::assertSame('Studio|@root/macros/core/ui.html.twig|11|4|debug|fallback|Powered by [Studio](https://www.aavion.media) '.$systemVersion.'|ok
', $html);
}
public function testItRendersSafeHtmlAttributes(): void
@@ -64,6 +66,16 @@ public function testItRendersCodemirrorSyntaxProviderAliases(): void
self::assertStringContainsString('data-code-editor-tab-size-value="2"', $html);
}
+ private function rootManifestVersion(): string
+ {
+ $projectDir = (string) self::getContainer()->getParameter('kernel.project_dir');
+ $result = (new ManifestParser())->parse((string) file_get_contents($projectDir.'/.manifest'));
+
+ self::assertTrue($result->isSuccess(), json_encode($result->toArray(), JSON_THROW_ON_ERROR));
+
+ return (string) $result->value()->get('APP_VERSION');
+ }
+
public function testItRendersGranularFormAndActionPartials(): void
{
self::bootKernel();
diff --git a/tests/View/ViewContextProviderTest.php b/tests/View/ViewContextProviderTest.php
index 2ce47bef..eca2ec02 100644
--- a/tests/View/ViewContextProviderTest.php
+++ b/tests/View/ViewContextProviderTest.php
@@ -10,8 +10,8 @@
use App\Core\Event\PublicEventDispatcher;
use App\Core\Event\PublicEventHookRegistry;
use App\Localization\TranslationLanguageCatalog;
-use App\View\PackageMacroRegistry;
-use App\View\SystemPackageMetadataProvider;
+use App\View\ExtensionMacroRegistry;
+use App\View\SystemExtensionMetadataProvider;
use App\View\ViewContextEvent;
use App\View\ViewContextProvider;
use App\Tests\Support\NullWorkflowResultMessageReporter;
@@ -21,24 +21,24 @@
final class ViewContextProviderTest extends TestCase
{
- public function testItDispatchesViewContextForPackageExtensions(): void
+ public function testItDispatchesViewContextForExtensions(): void
{
$dispatcher = new EventDispatcher();
$dispatcher->addListener(ViewContextEvent::class, static function (ViewContextEvent $event): void {
- $event->set('package_demo', ['enabled' => true]);
+ $event->set('extension_demo', ['enabled' => true]);
});
$context = (new ViewContextProvider(
- new SystemPackageMetadataProvider(dirname(__DIR__, 2)),
- new PackageMacroRegistry(),
+ new SystemExtensionMetadataProvider(dirname(__DIR__, 2)),
+ new ExtensionMacroRegistry(),
$this->localization(),
new PublicEventDispatcher($dispatcher, new PublicEventHookRegistry(), new NullWorkflowResultMessageReporter()),
))->context();
- self::assertSame('Studio', $context['system_package']['name']);
+ self::assertSame('Studio', $context['system_extension']['name']);
self::assertSame('de', $context['default_locale']);
self::assertSame('@root/macros/core/content.html.twig', $context['macro_namespaces']['core']['content']);
- self::assertSame(['enabled' => true], $context['package_demo']);
+ self::assertSame(['enabled' => true], $context['extension_demo']);
}
private function localization(): ContentRouteLocalization
diff --git a/tests/assets/alert_payload.test.mjs b/tests/assets/alert_payload.test.mjs
index b8fed53d..7eac3cd5 100644
--- a/tests/assets/alert_payload.test.mjs
+++ b/tests/assets/alert_payload.test.mjs
@@ -132,7 +132,7 @@ test('createAlertElement filters unsafe action links before rendering and storag
id: 'client-alert',
message: 'Client alert',
actions: [
- { label: 'Open', href: '/admin/packages', target: '_blank' },
+ { label: 'Open', href: '/admin/extensions', target: '_blank' },
{ label: 'Script', href: 'javascript:alert(1)' },
{ label: 'Hostless http', href: 'http:evil.example.test' },
{ label: 'External', href: 'https://example.test/privacy', target: '_self' },
@@ -143,13 +143,13 @@ test('createAlertElement filters unsafe action links before rendering and storag
const payload = JSON.parse(alert.dataset.alertPayload);
assert.equal(actions.length, 3);
- assert.equal(actions[0].href, '/admin/packages');
+ assert.equal(actions[0].href, '/admin/extensions');
assert.equal(actions[0].target, '_blank');
assert.equal(actions[0].rel, 'noopener noreferrer');
assert.equal(actions[1].href, 'https://example.test/privacy');
assert.equal(actions[2].dataset.alertActionEvent, 'operation-overlay:show');
assert.deepEqual(payload.actions, [
- { label: 'Open', href: '/admin/packages', target: '_blank' },
+ { label: 'Open', href: '/admin/extensions', target: '_blank' },
{ label: 'External', href: 'https://example.test/privacy', target: '_self' },
{ label: 'Event', event: 'operation-overlay:show', detail: { id: 'operation-1' } },
]);
diff --git a/tests/assets/controller_foundation.test.mjs b/tests/assets/controller_foundation.test.mjs
index 590cfb34..b87db789 100644
--- a/tests/assets/controller_foundation.test.mjs
+++ b/tests/assets/controller_foundation.test.mjs
@@ -255,7 +255,7 @@ test('operation overlay marks running detail actions as reusable', () => {
controller.updateOperationAlert({
status: 'running',
progress: { index: 1, total: 3 },
- label: 'Package registry refresh',
+ label: 'Extension registry refresh',
});
assert.equal(alertPayload.actions[0].event, 'operation-overlay:show');
@@ -263,7 +263,7 @@ test('operation overlay marks running detail actions as reusable', () => {
controller.updateOperationAlert({
status: 'success',
- label: 'Package registry refresh',
+ label: 'Extension registry refresh',
});
assert.equal(alertPayload.actions[0].detail.keepAlert, false);
diff --git a/translations/languages/de/admin.yaml b/translations/languages/de/admin.yaml
index eef80e73..c368b005 100644
--- a/translations/languages/de/admin.yaml
+++ b/translations/languages/de/admin.yaml
@@ -16,7 +16,7 @@ admin:
users: 'Benutzer'
user_groups: 'ACL-Gruppen'
user_reviews: 'Reviews'
- packages: 'Packages'
+ extensions: 'Extensions'
themes: 'Themes'
scheduler: 'Scheduler'
backups: 'Backups'
@@ -29,32 +29,36 @@ admin:
user_settings: 'Benutzer'
mail_settings: 'Mail'
security_settings: 'Sicherheit'
- package_settings: 'Pakete'
+ logging_settings: 'Logs'
+ extension_settings: 'Extensions'
statistics_settings: 'Statistiken'
api_settings: 'API'
+ acl_settings: 'ACL'
scheduler_settings: 'Scheduler'
system_info: 'System-Info'
actions:
- package_discovery:
+ extension_discovery:
label: 'Registry aktualisieren'
asset_rebuild:
label: 'Assets neu bauen'
cache_clear:
label: 'Cache leeren'
+ geoip_database_update:
+ label: 'GeoIP2-Datenbank laden'
dashboard:
title: 'Admin-Dashboard'
foundation_title: 'Backend-Fundament'
foundation_text: 'Administrative Routen werden jetzt durch den nativen Backend-Router aufgelöst. Funktionale Seiten können hier andocken, sobald Authentifizierung, Navigation und Zugriffsprüfungen bereit sind.'
- packages:
- title: 'Paketverwaltung'
- foundation_title: 'Paket-View-Fundament'
- foundation_text: 'Die Paketverwaltung dockt hier an, sobald Installations-, Update-, Aktivierungs- und Cleanup-Workflows verdrahtet sind.'
- registry_title: 'Registrierte Pakete'
- registry_text: 'In der Registry gefundene Pakete werden hier aufgelistet. Lifecycle-Aktionen docken an, sobald Operation-Flows verdrahtet sind.'
- empty: 'Noch sind keine Pakete registriert.'
+ extensions:
+ title: 'Extensionverwaltung'
+ foundation_title: 'Extension-View-Fundament'
+ foundation_text: 'Die Extensionverwaltung dockt hier an, sobald Installations-, Update-, Aktivierungs- und Cleanup-Workflows verdrahtet sind.'
+ registry_title: 'Registrierte Extensions'
+ registry_text: 'In der Registry gefundene Extensions werden hier aufgelistet. Lifecycle-Aktionen docken an, sobald Operation-Flows verdrahtet sind.'
+ empty: 'Noch sind keine Extensions registriert.'
unknown_value: 'Unbekannt'
table:
- package: 'Paket'
+ extension: 'Extension'
status: 'Status'
scopes: 'Scopes'
version: 'Version'
@@ -78,59 +82,59 @@ admin:
none: 'Keine Aktionen'
install:
label: 'Installieren'
- title: 'Paket installieren'
- text: 'Lade ein Paket-ZIP hoch. Das Paket-Manifest wird zuerst geprüft und die finale Installation wartet im ActionLog auf Bestätigung.'
- file: 'Paket-ZIP'
- submit: 'Paket prüfen'
+ title: 'Extension installieren'
+ text: 'Lade ein Extension-ZIP hoch. Das Extension-Manifest wird zuerst geprüft und die finale Installation wartet im ActionLog auf Bestätigung.'
+ file: 'Extension-ZIP'
+ submit: 'Extension prüfen'
close: 'Schließen'
meta:
author: 'Von %author%'
detail:
- overview_title: 'Paketübersicht'
+ overview_title: 'Extensionübersicht'
immutable: 'Unveränderlich'
description: 'Beschreibung'
author: 'Autor'
license: 'Lizenz'
dependencies: 'Abhängigkeiten'
- dependencies_empty: 'Keine Paketabhängigkeiten'
+ dependencies_empty: 'Keine Extensionabhängigkeiten'
homepage: 'Homepage'
source: 'Quelle'
readme_title: 'README'
lifecycle:
review_title: 'Änderungen prüfen'
- immutable: 'Dieses Systempaket ist unveränderlich und kann seinen Lifecycle-State nicht ändern.'
+ immutable: 'Diese System-Extension ist unveränderlich und kann ihren Lifecycle-State nicht ändern.'
unavailable: 'Diese Lifecycle-Aktion ist nicht verfügbar.'
- no_changes: 'Diese Aktion muss keinen Paketstatus ändern.'
+ no_changes: 'Diese Aktion muss keinen Extension-Status ändern.'
cancel: 'Abbrechen'
table:
action: 'Aktion'
activate:
label: 'Aktivieren'
- title: '%package% aktivieren'
- text: 'Prüfe Abhängigkeiten und Konflikte, bevor dieses Paket aktiviert wird.'
- submit: 'Paket aktivieren'
+ title: '%extension% aktivieren'
+ text: 'Prüfe Abhängigkeiten und Konflikte, bevor diese Extension aktiviert wird.'
+ submit: 'Extension aktivieren'
deactivate:
label: 'Deaktivieren'
- title: '%package% deaktivieren'
- text: 'Prüfe die geplante Statusänderung, bevor dieses Paket deaktiviert wird.'
- submit: 'Paket deaktivieren'
+ title: '%extension% deaktivieren'
+ text: 'Prüfe die geplante Statusänderung, bevor diese Extension deaktiviert wird.'
+ submit: 'Extension deaktivieren'
reset_fault:
label: 'Fehler zurücksetzen'
- title: 'Fehler für %package% zurücksetzen'
- text: 'Das Paket wird erneut validiert und bei erfolgreicher Wiederherstellung auf inaktiv gesetzt.'
+ title: 'Fehler für %extension% zurücksetzen'
+ text: 'Die Extension wird erneut validiert und bei erfolgreicher Wiederherstellung auf inaktiv gesetzt.'
submit: 'Fehler zurücksetzen'
purge:
label: 'Daten löschen'
- title: 'Daten für %package% löschen'
- text: 'Prüfe den Cleanup paketbezogener Daten, bevor diese Paketregistrierung zurückgesetzt wird.'
- warning: 'Dieser Schritt ist irreversibel. Paketeigene Einstellungen und spätere paketeigene Cleanup-Ziele werden gelöscht.'
+ title: 'Daten für %extension% löschen'
+ text: 'Prüfe den Cleanup extensionbezogener Daten, bevor diese Extension-Registrierung zurückgesetzt wird.'
+ warning: 'Dieser Schritt ist irreversibel. Extension-eigene Einstellungen und spätere extension-eigene Cleanup-Ziele werden gelöscht.'
submit: 'Daten löschen'
delete:
- label: 'Paket löschen'
- title: '%package% löschen'
- text: 'Prüfe die Dateisystem-Entfernung, bevor dieses Paket von der Festplatte gelöscht wird.'
- warning: 'Dieser Schritt ist irreversibel. Die Paketdateien werden gelöscht und der Registry-Eintrag bleibt als entfernt bestehen, bis Daten separat gelöscht werden.'
- submit: 'Paket löschen'
+ label: 'Extension löschen'
+ title: '%extension% löschen'
+ text: 'Prüfe die Dateisystem-Entfernung, bevor diese Extension von der Festplatte gelöscht wird.'
+ warning: 'Dieser Schritt ist irreversibel. Die Extension-Dateien werden gelöscht und der Registry-Eintrag bleibt als entfernt bestehen, bis Daten separat gelöscht werden.'
+ submit: 'Extension löschen'
themes:
title: 'Theme-Verwaltung'
foundation_title: 'Theme-Verwaltungsfundament'
@@ -145,7 +149,7 @@ admin:
text: 'Themes für Administrations- und Editor-Shells sowie der native System-Fallback werden hier aufgelistet.'
type:
system: 'System'
- package: 'Paket'
+ extension: 'Extension'
quick:
use: 'Verwenden'
active: 'Aktiv'
@@ -472,9 +476,9 @@ admin:
ui_alert_inbox_cleanup:
label: 'UI-Alert-Inbox-Cleanup'
description: 'Entfernt zwischengespeicherte Benachrichtigungen nach Ablauf ihrer Zustell-Aufbewahrung.'
- package_discovery:
- label: 'Paket-Discovery'
- description: 'Findet Pakete und aktualisiert die Paket-Registry.'
+ extension_discovery:
+ label: 'Extension-Discovery'
+ description: 'Findet Extensions und aktualisiert die Extension-Registry.'
statistics_snapshot:
label: 'Statistik-Snapshot'
description: 'Aktualisiert den gespeicherten Zugriffsstatistik-Snapshot, wenn Statistiken aktiviert sind.'
@@ -484,6 +488,9 @@ admin:
mercure_health:
label: 'Mercure-Health'
description: 'Prüft den Mercure-Hub und startet den lokalen Hub, sofern unterstützt.'
+ geoip2_database_update:
+ label: 'GeoIP2-Datenbankupdate'
+ description: 'Lädt die lokale MaxMind GeoIP2-City-Datenbank herunter und ersetzt sie atomar, wenn ein License-Key konfiguriert ist.'
table:
job: 'Job'
source: 'Quelle'
@@ -511,17 +518,17 @@ admin:
failure_count: 'Fehler in Folge'
run_url: 'Direkte Ausführungs-URL'
run_now_inactive: 'Aktiviere diesen Job, bevor du ihn manuell ausführst.'
- package_action_queue_warning: 'Dieser Paket-Job kann eine ActionQueue ausführen und wird über die Scheduler-Einstellung für Paket-ActionQueues gesteuert.'
+ extension_action_queue_warning: 'Dieser Extension-Job kann eine ActionQueue ausführen und wird über die Scheduler-Einstellung für Extension-ActionQueues gesteuert.'
form:
title: 'Job-Einstellungen'
enabled: 'Diesen Job automatisch ausführen'
cron_expression: 'Cron-Ausdruck'
- confirm_package_action_queue: 'Ich verstehe, dass dieser Paket-Job eine ActionQueue ausführen kann.'
+ confirm_extension_action_queue: 'Ich verstehe, dass dieser Extension-Job eine ActionQueue ausführen kann.'
saved: 'Scheduler-Job gespeichert.'
errors:
invalid_csrf: 'Das Scheduler-Formular ist abgelaufen. Bitte versuche es erneut.'
cron_invalid: 'Gib einen gültigen Cron-Ausdruck ein.'
- package_action_queue_confirmation_required: 'Bestätige den Paket-ActionQueue-Hinweis, bevor du diesen Job aktivierst.'
+ extension_action_queue_confirmation_required: 'Bestätige den Extension-ActionQueue-Hinweis, bevor du diesen Job aktivierst.'
runs:
title: 'Letzte Läufe'
empty: 'Dieser Job wurde noch nicht ausgeführt.'
@@ -590,22 +597,24 @@ admin:
logs:
title: 'Logs'
foundation_title: 'Log- und Audit-Fundament'
- foundation_text: 'Operations-Logs, Audit-Ansichten, Paketdiagnostik und sicherheitsrelevante Ereignisse docken hier an.'
+ foundation_text: 'Operations-Logs, Audit-Ansichten, Extensiondiagnostik und sicherheitsrelevante Ereignisse docken hier an.'
empty_value: 'n/a'
sources:
+ label: 'Logquellen'
application: 'Anwendung'
message: 'Meldungen'
audit: 'Audit'
access: 'Zugriffe'
+ security_signal: 'Security-Signale'
filters:
title: 'Filter'
source: 'Log'
level: 'Level'
- any_level: 'Alle Level'
search: 'Suche'
window: 'Zeitraum'
match: 'Treffer'
audit_action: 'Audit-Aktion'
+ signal_reason: 'Signal-Grund'
per_page: 'Zeilen'
limit: 'Limit'
contains: 'Enthält'
@@ -620,8 +629,6 @@ admin:
entries:
title: 'Einträge'
empty: 'Keine Log-Einträge entsprechen den aktuellen Filtern.'
- no_files: 'Für diese Auswahl existiert noch keine Log-Datei.'
- files: 'Gelesen wird: %files%'
open: 'Details'
page_summary: 'Seite %page% von %pages% · %total% Einträge'
previous: 'Zurück'
@@ -637,6 +644,8 @@ admin:
location: 'Ort'
user: 'Nutzer'
action: 'Aktion'
+ security_signal: 'Signal'
+ subject: 'Subjekt'
channel: 'Channel'
context: 'Kontext'
details: 'Details'
@@ -645,6 +654,31 @@ admin:
context: 'Kontext'
no_context: 'Es ist kein strukturierter Kontext verfügbar.'
raw: 'Rohzeile'
+ auto_bans:
+ active:
+ title: 'Aktive Auto-Bans'
+ empty: 'Keine aktiven Auto-Bans.'
+ open: 'Prüfen'
+ open_list: 'Aktive Auto-Bans öffnen'
+ disabled: 'Auto-Ban-Enforcement ist deaktiviert. Bestehende TTL-States bleiben bis zum Ablauf erhalten, werden aber nicht durchgesetzt.'
+ columns:
+ subject: 'Subjekt'
+ created_at: 'Erstellt'
+ expires_at: 'Läuft ab'
+ ttl: 'TTL-Sekunden'
+ details: 'Details'
+ detail:
+ title: 'Auto-Ban-Detail'
+ signals: 'Zugehörige Security-Signale'
+ no_signals: 'Für diesen aktiven Bann sind keine aufbewahrten Signale verfügbar.'
+ trigger_country: 'Auslöse-Land'
+ trigger_continent: 'Auslöse-Kontinent'
+ reset:
+ submit: 'Auto-Ban zurücksetzen'
+ saved: 'Auto-Ban zurückgesetzt.'
+ failed: 'Auto-Ban konnte nicht zurückgesetzt werden.'
+ alerts:
+ triggered: 'Auto-Ban für %subject% erstellt.'
statistics:
title: 'Statistiken'
access_title: 'Zugriffsstatistiken'
@@ -675,8 +709,12 @@ admin:
all: 'Alle gespeicherten Events'
settings:
title: 'Einstellungen'
+ acl:
+ title: 'ACL'
+ foundation_title: 'ACL-Matrix'
+ foundation_text: 'Owner-gesteuerte Feature-Berechtigungen für Admin-, Editor- und zukünftige Frontend-Surfaces.'
redirect_title: 'Einstellungsbereiche'
- redirect_text: 'Allgemeine Einstellungen sind der erste eingebaute Einstellungsbereich. Aktive Pakete mit einfachen Setting-Definitionen erscheinen unter den Paket-Einstellungen.'
+ redirect_text: 'Allgemeine Einstellungen sind der erste eingebaute Einstellungsbereich. Aktive Extensions mit einfachen Setting-Definitionen erscheinen unter den Extension-Einstellungen.'
general:
title: 'Allgemeine Einstellungen'
foundation_title: 'Allgemeines Konfigurationsfundament'
@@ -696,23 +734,41 @@ admin:
security:
title: 'Sicherheits-Einstellungen'
foundation_title: 'Sicherheits-Konfigurationsfundament'
- foundation_text: 'Sicherheits-, Captcha-, Logging- und Missbrauchsschutz-Einstellungen docken hier an.'
+ foundation_text: 'Sicherheits-, Captcha-, passive Signal-Retention- und Missbrauchsschutz-Einstellungen docken hier an.'
+ logging:
+ title: 'Log-Einstellungen'
+ foundation_title: 'Datenbank-Log-Projektionen'
+ foundation_text: 'Message-, Audit- und Access-Logs behalten separat ihre festen rotierenden File-Logs. Diese Einstellungen steuern, wie lange die durchsuchbaren Datenbank-Kopien verfügbar bleiben.'
statistics:
title: 'Statistik-Einstellungen'
foundation_title: 'Statistik-Konfiguration'
- foundation_text: 'Aufzeichnung und Anzeige von Zugriffsstatistiken können unabhängig vom Raw-Access-Logging deaktiviert werden. Raw-Access-Logs bleiben für operative Sicherheit verfügbar und werden 30 Tage aufbewahrt.'
+ foundation_text: 'Aufzeichnung, Anzeige und GeoIP-Anreicherung von Zugriffsstatistiken können unabhängig vom Raw-Access-Logging deaktiviert werden. Raw-Access-Logs bleiben für operative Sicherheit verfügbar und werden 30 Tage aufbewahrt.'
+ geoip:
+ status:
+ title: 'GeoIP2-Status'
+ provider: 'Provider'
+ state: 'Status'
+ database_edition: 'Datenbank-Edition'
+ database_build_date: 'Datenbank-Build-Datum'
+ failure_code: 'Fehlercode'
+ values:
+ ready: 'Bereit'
+ disabled: 'Deaktiviert'
+ unconfigured: 'Nicht konfiguriert'
+ unavailable: 'Nicht verfügbar'
+ unknown: 'Unbekannt'
api:
title: 'API-Einstellungen'
foundation_title: 'API-Verfügbarkeit'
foundation_text: 'API-Zugriff kann für normale Nutzer deaktiviert werden, während Owner das Key-Management für operative Integrationen wie Scheduler-Aktivierung behalten.'
- packages:
- title: 'Paket-Einstellungen'
- foundation_title: 'Pakete mit Einstellungen'
- empty: 'Noch kein aktives Paket stellt Einstellungen bereit.'
- package:
- title: 'Einstellungen für %package%'
- foundation_title: 'Paket-Einstellungen'
- empty: 'Dieses Paket stellt keine konfigurierbaren Einstellungen bereit.'
+ extensions:
+ title: 'Extension-Einstellungen'
+ foundation_title: 'Extensions mit Einstellungen'
+ empty: 'Noch keine aktive Extension stellt Einstellungen bereit.'
+ extension:
+ title: 'Einstellungen für %extension%'
+ foundation_title: 'Extension-Einstellungen'
+ empty: 'Diese Extension stellt keine konfigurierbaren Einstellungen bereit.'
options: '%count% Option(en)'
validation: '%count% Validierungsregel(n)'
scheduler:
@@ -738,6 +794,153 @@ admin:
required: 'Dieses Feld ist erforderlich.'
save_failed: 'Die Einstellungen konnten nicht gespeichert werden.'
default_acl_group_unavailable: 'Trage eine bestehende ACL-Gruppe mit Mindestrolle User oder niedriger ein oder lasse das Feld leer.'
+ fields:
+ captcha_enabled:
+ label: 'Captcha aktivieren'
+ captcha_provider:
+ label: 'Captcha-Provider'
+ captcha_preview:
+ label: 'Captcha-Vorschau'
+ rate_limit_mode:
+ label: 'Ratenbegrenzung'
+ auto_ban_enabled:
+ label: 'Auto-Ban aktivieren'
+ help: 'Blockiert wiederholt verdächtige Visitor/IP-Aktivität vorübergehend. Trusted User bleiben ausgenommen.'
+ auto_ban_trusted_access_level:
+ label: 'Trusted-User-Level'
+ help: 'Registrierte Benutzer ab diesem Access-Level werden nie automatisch gebannt.'
+ auto_ban_score_threshold:
+ label: 'Auto-Ban-Score-Schwelle'
+ help: 'Score, der innerhalb einer Stunde erreicht werden muss, bevor ein neuer Visitor-Bann entstehen kann. Reine IP-Banns verwenden intern eine höhere Schwelle.'
+ auto_ban_new_ban_owner_alerts:
+ label: 'Alerts für neue Auto-Bans aktivieren'
+ help: 'Stellt Owner-Accounts eine versteckte Warnung zu, wenn ein neuer aktiver Auto-Ban erzeugt wird.'
+ audit_enabled:
+ label: 'Audit-Logging aktivieren'
+ audit_events:
+ label: 'Audit-Ereigniskategorien'
+ security_signal_retention_days:
+ label: 'Retention für Security-Signale'
+ help: 'Retention für passive Security-Signale in Tagen. Werte über 30 Tagen sind nicht erlaubt, weil Signale Request-bezogene Identifier enthalten können.'
+ security_probe_path_patterns:
+ label: 'Pfadmuster für verdächtige Probes'
+ help: 'Ein regulärer Ausdruck pro Zeile. Quoted-CSV-Imports werden akzeptiert. Ungültige oder leere Listen fallen auf die geschützten Defaults zurück.'
+ options:
+ captcha:
+ none: 'Kein Captcha-Provider'
+ rate_limit_mode:
+ off: 'Aus'
+ standard: 'Standard'
+ strict: 'Streng'
+ panic: 'Panik'
+ access_level:
+ user: 'Benutzer'
+ moderator: 'Moderator'
+ author: 'Autor'
+ publisher: 'Publisher'
+ curator: 'Kurator'
+ manager: 'Manager'
+ director: 'Director'
+ admin: 'Admin'
+ owner: 'Owner'
+ audit:
+ authentication: 'Authentifizierungsereignisse'
+ backend_actions: 'Backend-Wartungsaktionen'
+ operations: 'Operations-Wartungsaktionen'
+ extensions: 'Extension-Lifecycle-Aktionen'
+ settings: 'Einstellungsänderungen'
+ other: 'Weitere zukünftige Audit-Ereignisse'
+ acl:
+ actions:
+ save: 'ACL-Matrix speichern'
+ categories:
+ diagnostics: 'Diagnostik'
+ operations: 'Operationen'
+ extensions: 'Extensions'
+ extension_settings: 'Extension-Einstellungen'
+ settings: 'Einstellungen'
+ system: 'System'
+ users: 'Benutzer'
+ empty: 'Für diese Surface sind noch keine Feature-Flags registriert.'
+ groups:
+ empty: 'Keine ACL-Gruppen können diese Surface gewähren.'
+ non_configurable: 'Read-only'
+ read_only_action: 'Du darfst diese Aktion prüfen, aber die aktuelle ACL-Policy erlaubt keine Mutation.'
+ states:
+ inherit: 'Erben'
+ denied: 'Verweigert'
+ visible: 'Sichtbar'
+ mutable: 'Mutierbar'
+ surfaces:
+ label: 'ACL-Surfaces'
+ admin: 'Admin'
+ admin_text: 'Administrative Feature-Flags. ACL-Gruppen können Berechtigungen nur an Benutzer vergeben, die bereits das Admin-Surface-Gate passieren.'
+ editor: 'Editor'
+ editor_text: 'Für zukünftige Editor-Feature-Flags vorbereitet. Editor-Provider können hier später Zeilen registrieren.'
+ frontend: 'Frontend'
+ frontend_text: 'Reserviert für ausdrücklich entworfene zukünftige Frontend-Features wie Inline-Editing.'
+ table:
+ admin_state: 'Admin'
+ default: 'Default'
+ feature: 'Feature'
+ groups: 'ACL-Gruppen-Grants'
+ features:
+ admin_settings_security:
+ label: 'Sicherheits-Einstellungen'
+ description: 'Security-Policy, Audit-Kategorien, Signal-Retention und verdächtige Probe-Pfade.'
+ admin_settings_logging:
+ label: 'Log-Retention-Einstellungen'
+ description: 'Datenbank-Lookup-Retention für Message-, Audit- und Access-Log-Projektionen.'
+ admin_settings_statistics:
+ label: 'Statistik-Einstellungen'
+ description: 'Aufzeichnung und Anzeige von Zugriffsstatistiken.'
+ admin_settings_statistics_geoip:
+ label: 'GeoIP-Einstellungen'
+ description: 'GeoIP-Aktivierung, lokaler Datenbankpfad, geschützter MaxMind-License-Key und Status.'
+ admin_settings_api:
+ label: 'API-Einstellungen'
+ description: 'Globale API-Verfügbarkeit und CORS-Erweiterungen.'
+ admin_settings_scheduler:
+ label: 'Scheduler-Einstellungen'
+ description: 'Scheduler-Aktivierung, Web-Triggering, GET-Authentifizierung und Extension-Action-Queues.'
+ admin_settings_extensions:
+ label: 'Extension-Einstellungen'
+ description: 'Core-Einstellungen für Extension-Updates und extension-eigene Einstellungsseiten.'
+ admin_settings_extension:
+ description: 'Extension-eigene Einstellungsseite für eine aktive Extension.'
+ admin_logs:
+ label: 'Logs'
+ description: 'Administrative Log-Review-Flächen.'
+ admin_extensions:
+ label: 'Extensions und Themes'
+ description: 'Extension- und Themeverwaltung inklusive Installation, Aktivierung, Deaktivierung, Reparatur, Löschung und Purge-Workflows.'
+ admin_extensions_self_update:
+ label: 'System-Extension-Self-Update'
+ description: 'Owner-only Self-Update-Workflows für die System-Extension.'
+ admin_backup_restore:
+ label: 'Backup, Export und Restore'
+ description: 'Full-Data-Backup, Export, Download und Restore-Workflows.'
+ admin_operations:
+ label: 'Operationen'
+ description: 'Live-Operation-Inspektion und operative Statusflächen.'
+ admin_actions_maintenance:
+ label: 'Wartungsaktionen'
+ description: 'Manuelle Wartungsaktionen wie Cache-Clear und Asset-Rebuild.'
+ admin_scheduler:
+ label: 'Scheduler'
+ description: 'Scheduler-Task-Inspektion und Run-Controls.'
+ admin_users:
+ label: 'Benutzer'
+ description: 'Benutzeradministration und Account-Verwaltung.'
+ admin_users_acl:
+ label: 'Benutzer-ACL-Gruppen'
+ description: 'ACL-Gruppenadministration und Membership-Review.'
+ admin_users_review:
+ label: 'Benutzer-Reviews'
+ description: 'Queues für Einladungen, Registrierungen und Benutzer-Reviews.'
+ admin_support:
+ label: 'Support-Bundles'
+ description: 'Erzeugung und Download von Diagnose- oder Support-Bundles.'
fields:
site_title:
label: 'Seitentitel'
@@ -786,10 +989,50 @@ admin:
label: 'Captcha-Provider'
captcha_preview:
label: 'Captcha-Vorschau'
+ rate_limit_mode:
+ label: 'Ratenbegrenzung'
+ auto_ban_enabled:
+ label: 'Auto-Ban aktivieren'
+ help: 'Blockiert wiederholt verdächtige Visitor/IP-Aktivität vorübergehend. Trusted User bleiben ausgenommen.'
+ auto_ban_trusted_access_level:
+ label: 'Trusted-User-Level'
+ help: 'Registrierte Benutzer ab diesem Access-Level werden nie automatisch gebannt.'
+ auto_ban_score_threshold:
+ label: 'Auto-Ban-Score-Schwelle'
+ help: 'Score, der innerhalb einer Stunde erreicht werden muss, bevor ein neuer Visitor-Bann entstehen kann. Reine IP-Banns verwenden intern eine höhere Schwelle.'
+ auto_ban_new_ban_owner_alerts:
+ label: 'Alerts für neue Auto-Bans aktivieren'
+ help: 'Stellt Owner-Accounts eine versteckte Warnung zu, wenn ein neuer aktiver Auto-Ban erzeugt wird.'
audit_enabled:
label: 'Audit-Logging aktivieren'
audit_events:
label: 'Audit-Ereigniskategorien'
+ message_log_retention_days:
+ label: 'Datenbank-Retention für Meldungslogs'
+ help: 'Retention der Datenbank-Lookup-Kopie in Tagen. File-Logs behalten separat ihre feste 30-Tage-Rotation.'
+ audit_log_retention_days:
+ label: 'Datenbank-Retention für Audit-Logs'
+ help: 'Retention der Datenbank-Lookup-Kopie in Tagen. Werte über 30 Tagen sind nicht erlaubt, weil Audit-Daten Request-bezogene Identifier enthalten können.'
+ access_log_retention_days:
+ label: 'Datenbank-Retention für Access-Logs'
+ help: 'Retention der Datenbank-Lookup-Kopie in Tagen. Werte über 30 Tagen sind nicht erlaubt, weil Access-Logs IP-abgeleitete Daten enthalten können.'
+ security_signal_retention_days:
+ label: 'Retention für Security-Signale'
+ help: 'Retention für passive Security-Signale in Tagen. Werte über 30 Tagen sind nicht erlaubt, weil Signale Request-bezogene Identifier enthalten können.'
+ security_probe_path_patterns:
+ label: 'Pfadmuster für verdächtige Probes'
+ help: 'Ein regulärer Ausdruck pro Zeile. Quoted-CSV-Imports werden akzeptiert. Ungültige oder leere Listen fallen auf die geschützten Defaults zurück.'
+ geoip_enabled:
+ label: 'GeoIP-Lookups aktivieren'
+ help: 'Wenn deaktiviert oder nicht verfügbar, behalten Logs und Statistiken normalisierte n/a-Ortswerte.'
+ geoip_license_link:
+ label: 'Kostenlosen GeoLite2-License-Key bei MaxMind erhalten'
+ geoip_database_path:
+ label: 'MaxMind-Datenbankpfad'
+ help: 'Projektrelativer Pfad zur lokalen .mmdb-Datenbank. Downloads werden atomar in diesen Pfad geschrieben.'
+ geoip_license_key:
+ label: 'MaxMind-License-Key'
+ help: 'Wird als sensitive Konfiguration gespeichert. Ein gespeicherter Key aktiviert die GeoIP2-Download-Aktion auf dieser Seite.'
statistics_enabled:
label: 'Zugriffsstatistiken aktivieren'
statistics_respect_dnt:
@@ -803,34 +1046,44 @@ admin:
api_cors_allowed_origins:
label: 'Erlaubte API-CORS-Origins'
help: 'JSON-Array exakter Origins wie ["https://client.example"]. Der Wildcard "*" sollte nur bewusst verwendet werden.'
- package_update_interval:
+ extension_update_interval:
label: 'Update-Check-Intervall'
- package_auto_updates:
- label: 'Automatische Paket-Updates erlauben'
+ extension_auto_updates:
+ label: 'Automatische Extension-Updates erlauben'
scheduler_enabled:
label: 'Scheduler aktivieren'
scheduler_get_auth_enabled:
label: 'GET-Authentifizierung für /cron/run erlauben'
help: 'Bearer-Authentifizierung bleibt bevorzugt. Aktiviere GET-Authentifizierung nur für Hosting-Scheduler, die keine Header senden können.'
- scheduler_package_action_queues_enabled:
- label: 'Scheduler-Action-Queues aus Paketen erlauben'
- help: 'Action-Queues aus Paketen können Datei- oder Prozessoperationen ausführen. Core-Scheduler-Queues gelten separat als vertrauenswürdig.'
+ scheduler_extension_action_queues_enabled:
+ label: 'Scheduler-Action-Queues aus Extensions erlauben'
+ help: 'Action-Queues aus Extensions können Datei- oder Prozessoperationen ausführen. Core-Scheduler-Queues gelten separat als vertrauenswürdig.'
scheduler_web_trigger_enabled:
label: 'Scheduler durch Seitenaufrufe starten'
help: 'Startet nach geeigneten Seitenaufrufen höchstens einmal pro Minute einen losgelösten Scheduler-Lauf. Nutze einen echten Cronjob, wenn verfügbar.'
options:
dashboard:
system_status: 'Systemstatus'
- packages: 'Paketstatus'
+ extensions: 'Extension-Status'
recent_activity: 'Letzte Aktivität'
setup_warnings: 'Setup-Warnungen'
captcha:
none: 'Kein Captcha-Provider'
+ rate_limit_mode:
+ off: 'Aus'
+ standard: 'Standard'
+ strict: 'Streng'
+ panic: 'Panik'
+ access_level:
+ manager: 'Manager'
+ director: 'Director'
+ admin: 'Admin'
+ owner: 'Owner'
audit:
authentication: 'Authentifizierungsereignisse'
backend_actions: 'Backend-Wartungsaktionen'
operations: 'Operations-Wartungsaktionen'
- packages: 'Paket-Lifecycle-Aktionen'
+ extensions: 'Extension-Lifecycle-Aktionen'
settings: 'Einstellungsänderungen'
other: 'Weitere zukünftige Audit-Ereignisse'
registration:
diff --git a/translations/languages/de/editor.yaml b/translations/languages/de/editor.yaml
index 0943f24a..7d53e8b3 100644
--- a/translations/languages/de/editor.yaml
+++ b/translations/languages/de/editor.yaml
@@ -10,6 +10,6 @@ editor:
title: 'Editor-Dashboard'
status: 'Content-Arbeitsbereich'
foundation_title: 'Editor-Fundament'
- foundation_text: 'Content-Routen können an diese Shell andocken, sobald Editor-Workflows, Schema-Werkzeuge und Package-Beiträge verdrahtet sind.'
+ foundation_text: 'Content-Routen können an diese Shell andocken, sobald Editor-Workflows, Schema-Werkzeuge und Extension-Beiträge verdrahtet sind.'
message:
title: 'Editor-Route'
diff --git a/translations/languages/de/message.yaml b/translations/languages/de/message.yaml
index 75db020e..dc708dc8 100644
--- a/translations/languages/de/message.yaml
+++ b/translations/languages/de/message.yaml
@@ -8,97 +8,108 @@ message:
unknown_key: 'Manifest-Key "%key%" wird hier nicht unterstützt.'
parsed: 'Manifest wurde geparst.'
validated: 'Manifest wurde validiert.'
- package:
+ extension:
identifier:
- invalid: 'Paket-Identifier "%identifier%" enthält nicht unterstützte Zeichen.'
- manifest_unreadable: 'Paket-Manifest "%path%" kann nicht gelesen werden.'
- required_file_missing: 'Erforderliche Paketdatei "%path%" fehlt.'
- required_directory_missing: 'Erforderliches Paketverzeichnis "%path%" fehlt.'
- file_unreadable: 'Paketdatei "%path%" kann nicht gelesen werden.'
+ invalid: 'Extension-Identifier "%identifier%" enthält nicht unterstützte Zeichen.'
+ manifest_unreadable: 'Extension-Manifest "%path%" kann nicht gelesen werden.'
+ required_file_missing: 'Erforderliche Extension-Datei "%path%" fehlt.'
+ required_directory_missing: 'Erforderliches Extension-Verzeichnis "%path%" fehlt.'
+ file_unreadable: 'Extension-Datei "%path%" kann nicht gelesen werden.'
php_syntax_error: 'PHP-Syntaxprüfung für "%path%" fehlgeschlagen.'
- php_namespace_invalid: 'PHP-Quelle "%path%" muss den Paket-Namespace "%expected_namespace%" verwenden.'
+ php_namespace_invalid: 'PHP-Quelle "%path%" muss den Extension-Namespace "%expected_namespace%" verwenden.'
twig_syntax_error: 'Twig-Syntaxprüfung für "%path%" fehlgeschlagen.'
- translation_fallback_missing: 'Paket-Translation-Quellen für "%package%" müssen mindestens einen Fallback-Katalog unter "languages/%locale%" enthalten.'
- translation_namespace_invalid: 'Paket-Translation-Datei "%path%" muss Keys unterhalb von "pkg.%package%" definieren.'
+ translation_fallback_missing: 'Extension-Translation-Quellen für "%extension%" müssen mindestens einen Fallback-Katalog unter "languages/%locale%" enthalten.'
+ translation_namespace_invalid: 'Extension-Translation-Datei "%path%" muss Keys unterhalb von "ext.%extension%" definieren.'
json_syntax_error: 'JSON-Syntaxprüfung für "%path%" fehlgeschlagen.'
yaml_syntax_error: 'YAML-Syntaxprüfung für "%path%" fehlgeschlagen.'
css_syntax_error: 'CSS-Syntaxprüfung für "%path%" fehlgeschlagen.'
- css_namespace_invalid: 'CSS-Klasse "%class%" in "%path%" muss den paket-eigenen Prefix "%expected_prefix%" verwenden.'
+ css_namespace_invalid: 'CSS-Klasse "%class%" in "%path%" muss den extension-eigenen Prefix "%expected_prefix%" verwenden.'
javascript_syntax_error: 'JavaScript-Syntaxprüfung für "%path%" fehlgeschlagen.'
- scope_invalid: 'Paket-Scope "%scope%" wird nicht unterstützt.'
- template_path_invalid: 'Paket-Template "%path%" ist durch Root-, Provider- oder Makro-Namespace-Regeln für Paket-Scope "%scope%" nicht erlaubt.'
- template_reference_invalid: 'Paket-Template "%path%" darf Template "%reference%" außerhalb des eigenen Template-Scopes nicht referenzieren.'
+ scope_invalid: 'Extension-Scope "%scope%" wird nicht unterstützt.'
+ template_path_invalid: 'Extension-Template "%path%" ist durch Root-, Provider- oder Makro-Namespace-Regeln für Extension-Scope "%scope%" nicht erlaubt.'
+ template_reference_invalid: 'Extension-Template "%path%" darf Template "%reference%" außerhalb des eigenen Template-Scopes nicht referenzieren.'
policy:
- blocked_path: 'Paketpfad "%path%" wird durch die Paket-Policy blockiert (%reason%).'
- warned_path: 'Paketpfad "%path%" sollte durch die Paket-Policy geprüft werden (%reason%).'
- blocked_php_capability: 'Paket-PHP-Datei "%path%" nutzt die blockierte Capability "%capability%" (%reason%). Nutze stattdessen einen dokumentierten Extension-Point.'
- copy_source_missing: 'Paket-Kopierquelle "%path%" fehlt.'
- copy_source_symlink: 'Paket-Kopierquelle "%path%" darf kein Symlink sein.'
- asset_sync_completed: 'Paket-Asset-Sync hat %assets% Asset(s) aus %packages% Paket(en) gespiegelt.'
- asset_sync_failed: 'Paket-Asset-Sync fehlgeschlagen: %message%'
- asset_rebuild_queued: 'Paket-Asset-Rebuild wurde durch "%trigger%" eingereiht.'
- asset_rebuild_queue_failed: 'Paket-Asset-Rebuild konnte durch "%trigger%" nicht eingereiht werden.'
+ blocked_path: 'Extension-Pfad "%path%" wird durch die Extension-Policy blockiert (%reason%).'
+ warned_path: 'Extension-Pfad "%path%" sollte durch die Extension-Policy geprüft werden (%reason%).'
+ blocked_php_capability: 'Extension-PHP-Datei "%path%" nutzt die blockierte Capability "%capability%" (%reason%). Nutze stattdessen einen dokumentierten Extension-Point.'
+ copy_source_missing: 'Extension-Kopierquelle "%path%" fehlt.'
+ copy_source_symlink: 'Extension-Kopierquelle "%path%" darf kein Symlink sein.'
+ asset_sync_completed: 'Extension-Asset-Sync hat %assets% Asset(s) aus %extensions% Extension(en) gespiegelt.'
+ asset_sync_failed: 'Extension-Asset-Sync fehlgeschlagen: %message%'
+ asset_rebuild_queued: 'Extension-Asset-Rebuild wurde durch "%trigger%" eingereiht.'
+ asset_rebuild_queue_failed: 'Extension-Asset-Rebuild konnte durch "%trigger%" nicht eingereiht werden.'
asset:
- contribution_package_invalid: 'Paket-Asset-Contribution benötigt einen Paket-Identifier.'
- contribution_type_invalid: 'Paket-Asset-Contribution-Typ "%type%" wird nicht unterstützt.'
- contribution_path_invalid: 'Paket-Asset-Pfad "%path%" muss projektrelativ sein.'
- contribution_path_traversal: 'Paket-Asset-Pfad "%path%" darf keine übergeordneten Verzeichnisse durchlaufen.'
- discovery_queued: 'Paket-Discovery wurde durch "%trigger%" eingereiht.'
- discovery_queue_failed: 'Paket-Discovery konnte durch "%trigger%" nicht eingereiht werden.'
- discovery_completed: 'Paket-Discovery hat %count% Kandidat(en) gefunden.'
- validation_completed: 'Paket "%package%" hat die Validierung bestanden.'
- copy_plan_created: 'Paket-Kopierplan enthält %count% Datei(en).'
+ contribution_extension_invalid: 'Extension-Asset-Contribution benötigt einen Extension-Identifier.'
+ contribution_type_invalid: 'Extension-Asset-Contribution-Typ "%type%" wird nicht unterstützt.'
+ contribution_path_invalid: 'Extension-Asset-Pfad "%path%" muss projektrelativ sein.'
+ contribution_path_traversal: 'Extension-Asset-Pfad "%path%" darf keine übergeordneten Verzeichnisse durchlaufen.'
+ database:
+ contribution_invalid: 'Extension-Datenbank-Contribution ist ungültig (%reason%).'
+ sync_completed: 'Datenbank-Sync für Extension "%extension%" hat %count% Tabelle(n) erstellt.'
+ purge_completed: 'Datenbank-Purge für Extension "%extension%" hat %count% Tabelle(n) gelöscht.'
+ content_schema:
+ contribution_invalid: 'Extension-Content-Schema-Contribution ist ungültig (%reason%).'
+ sync_completed: 'Content-Schema-Sync für Extension "%extension%" hat %count% Schema/Schemata erstellt oder versioniert.'
+ purge_completed: 'Content-Schema-Purge für Extension "%extension%" hat %count% Schema/Schemata gelöscht.'
+ purge_retained: 'Content-Schema-Purge für Extension "%extension%" hat %count% referenzierte Schema/Schemata behalten.'
+ content_archived: '%count% Content-Element(e) mit deaktivierten Extension-Schemata wurden archiviert.'
+ discovery_queued: 'Extension-Discovery wurde durch "%trigger%" eingereiht.'
+ discovery_queue_failed: 'Extension-Discovery konnte durch "%trigger%" nicht eingereiht werden.'
+ discovery_completed: 'Extension-Discovery hat %count% Kandidat(en) gefunden.'
+ validation_completed: 'Extension "%extension%" hat die Validierung bestanden.'
+ copy_plan_created: 'Extension-Kopierplan enthält %count% Datei(en).'
registry:
- sync_completed: 'Paket-Registry-Sync hat %count% Änderung(en) erfasst.'
- registered: 'Paket "%package%" wurde inaktiv registriert.'
- updated: 'Registry-Metadaten für Paket "%package%" wurden aktualisiert.'
- removed: 'Paket "%package%" fehlt im Dateisystem und wurde als entfernt markiert.'
- faulty: 'Paket "%package%" hat die Validierung nicht bestanden und wurde als fehlerhaft markiert.'
+ sync_completed: 'Extension-Registry-Sync hat %count% Änderung(en) erfasst.'
+ registered: 'Extension "%extension%" wurde inaktiv registriert.'
+ updated: 'Registry-Metadaten für Extension "%extension%" wurden aktualisiert.'
+ removed: 'Extension "%extension%" fehlt im Dateisystem und wurde als entfernt markiert.'
+ faulty: 'Extension "%extension%" hat die Validierung nicht bestanden und wurde als fehlerhaft markiert.'
install:
- upload_invalid: 'Der Paket-Upload fehlt, ist ungültig oder keine ZIP-Datei.'
- zip_invalid: 'Das Paket-ZIP konnte nicht sicher gelesen werden.'
- root_invalid: 'Das Paket-ZIP muss genau ein Paket-Root mit .manifest-Datei enthalten.'
- ready: 'Paket "%package%" Version %version% kann jetzt installiert werden. Fortfahren?'
- overwrite: 'Paket "%package%" existiert bereits und wird bei aktivem Status deaktiviert, anschließend überschrieben und danach per Discovery neu registriert.'
- version_blocked: 'Paket "%package%" Version %version% kann die registrierte Version %installed_version% nicht ersetzen. Downgrades sind blockiert und Same-Version-Replacements sind nur für fehlerhafte oder entfernte Pakete erlaubt; entferne und purge das bestehende Paket zuerst, wenn dieser Austausch beabsichtigt ist.'
- completed: 'Paket "%package%" wurde installiert.'
+ upload_invalid: 'Der Extension-Upload fehlt, ist ungültig oder keine ZIP-Datei.'
+ zip_invalid: 'Das Extension-ZIP konnte nicht sicher gelesen werden.'
+ root_invalid: 'Das Extension-ZIP muss genau ein Extension-Root mit .manifest-Datei enthalten.'
+ ready: 'Extension "%extension%" Version %version% kann jetzt installiert werden. Fortfahren?'
+ overwrite: 'Extension "%extension%" existiert bereits und wird bei aktivem Status deaktiviert, anschließend überschrieben und danach per Discovery neu registriert.'
+ version_blocked: 'Extension "%extension%" Version %version% kann die registrierte Version %installed_version% nicht ersetzen. Downgrades sind blockiert und Same-Version-Replacements sind nur für fehlerhafte oder entfernte Extensions erlaubt; entferne und purge das bestehende Extension zuerst, wenn dieser Austausch beabsichtigt ist.'
+ completed: 'Extension "%extension%" wurde installiert.'
lifecycle:
- not_found: 'Paket "%package%" ist nicht registriert.'
- status_blocked: 'Paket "%package%" kann den Lifecycle-Status nicht wechseln, solange es "%status%" ist.'
- activated: 'Paket "%package%" wurde aktiviert.'
- deactivated: 'Paket "%package%" wurde deaktiviert.'
- dependent_deactivated: 'Paket "%package%" wurde deaktiviert, weil die Abhängigkeit "%dependency%" nicht mehr verfügbar ist.'
- cleanup_completed: 'Paket "%package%"-Cleanup abgeschlossen.'
- removed: 'Paket "%package%" wurde entfernt.'
- purged: 'Registry-Eintrag für Paket "%package%" wurde gelöscht.'
- fault_reset: 'Fehlerstatus für Paket "%package%" wurde nach der Validierung zurückgesetzt.'
- runtime_failure: 'Paket "%package%" wurde nach einem Runtime-Fehler als fehlerhaft markiert.'
- php_load_failed: 'PHP-Loader für Paket "%package%" ist fehlgeschlagen und das Paket wurde als fehlerhaft markiert.'
- rolled_back: 'Paket-Lifecycle-Änderungen wurden für %count% Paket(e) zurückgerollt.'
+ not_found: 'Extension "%extension%" ist nicht registriert.'
+ status_blocked: 'Extension "%extension%" kann den Lifecycle-Status nicht wechseln, solange es "%status%" ist.'
+ single_active_conflict: 'Extensions "%extensions%" können nicht gemeinsam aktiviert werden, weil Scope "%scope%" nur eine aktive Extension erlaubt.'
+ activated: 'Extension "%extension%" wurde aktiviert.'
+ deactivated: 'Extension "%extension%" wurde deaktiviert.'
+ dependent_deactivated: 'Extension "%extension%" wurde deaktiviert, weil die Abhängigkeit "%dependency%" nicht mehr verfügbar ist.'
+ cleanup_completed: 'Extension "%extension%"-Cleanup abgeschlossen.'
+ removed: 'Extension "%extension%" wurde entfernt.'
+ purged: 'Registry-Eintrag für Extension "%extension%" wurde gelöscht.'
+ fault_reset: 'Fehlerstatus für Extension "%extension%" wurde nach der Validierung zurückgesetzt.'
+ runtime_failure: 'Extension "%extension%" wurde nach einem Runtime-Fehler als fehlerhaft markiert.'
+ php_load_failed: 'PHP-Loader für Extension "%extension%" ist fehlgeschlagen und das Extension wurde als fehlerhaft markiert.'
+ rolled_back: 'Extension-Lifecycle-Änderungen wurden für %count% Extension(e) zurückgerollt.'
runtime:
- contribution_unsupported: 'Paket "%package%" hat einen nicht unterstützten Runtime-Contribution-Typ "%type%" zurückgegeben.'
+ contribution_unsupported: 'Extension "%extension%" hat einen nicht unterstützten Runtime-Contribution-Typ "%type%" zurückgegeben.'
live:
- endpoint_path_invalid: 'Paket "%package%" wollte den Live-Endpunkt-Pfad "%path%" außerhalb des eigenen /api/live/{package}/-Namespace registrieren.'
- endpoint_handler_invalid: 'Paket "%package%" wollte den Live-Endpunkt-Handler "%handler%" außerhalb des eigenen Handler-Namespace registrieren.'
- endpoint_reserved: 'Paket "%package%" kann keine Live-Endpunkte mit reserviertem System-Slug "%slug%" registrieren.'
+ endpoint_path_invalid: 'Extension "%extension%" wollte den Live-Endpunkt-Pfad "%path%" außerhalb des eigenen /api/live/{extension}/-Namespace registrieren.'
+ endpoint_handler_invalid: 'Extension "%extension%" wollte den Live-Endpunkt-Handler "%handler%" außerhalb des eigenen Handler-Namespace registrieren.'
+ endpoint_reserved: 'Extension "%extension%" kann keine Live-Endpunkte mit reserviertem System-Slug "%slug%" registrieren.'
setting:
- read_failed: 'Paket-Einstellung "%package%:%key%" konnte nicht gelesen werden.'
- write_failed: 'Paket-Einstellung "%package%:%key%" konnte nicht geschrieben werden.'
- delete_failed: 'Paket-Einstellungen für "%package%" konnten nicht gelöscht werden.'
- value_invalid: 'Paket-Einstellung "%package%:%key%" enthält einen ungültigen gespeicherten Wert.'
+ read_failed: 'Extension-Einstellung "%extension%:%key%" konnte nicht gelesen werden.'
+ write_failed: 'Extension-Einstellung "%extension%:%key%" konnte nicht geschrieben werden.'
+ delete_failed: 'Extension-Einstellungen für "%extension%" konnten nicht gelöscht werden.'
+ value_invalid: 'Extension-Einstellung "%extension%:%key%" enthält einen ungültigen gespeicherten Wert.'
dependency:
- missing: 'Paket-Abhängigkeit "%package%" für "%required_by%" ist nicht installiert.'
- invalid: 'Paket "%package%" deklariert fehlerhafte Paket-Abhängigkeiten.'
- version_unsatisfied: 'Paket-Abhängigkeit "%package%" erfordert mindestens Version %required_version%; installiert ist %installed_version%.'
- status_blocked: 'Paket-Abhängigkeit "%package%" kann nicht genutzt werden, solange sie "%status%" ist.'
- cycle: 'Zirkuläre Paket-Abhängigkeit erkannt: %cycle%.'
- resolved: 'Paket "%package%" hat %count% Abhängigkeits-Eintrag(e) aufgelöst.'
+ missing: 'Extension-Abhängigkeit "%extension%" für "%required_by%" ist nicht installiert.'
+ invalid: 'Extension "%extension%" deklariert fehlerhafte Extension-Abhängigkeiten.'
+ version_unsatisfied: 'Extension-Abhängigkeit "%extension%" erfordert mindestens Version %required_version%; installiert ist %installed_version%.'
+ status_blocked: 'Extension-Abhängigkeit "%extension%" kann nicht genutzt werden, solange sie "%status%" ist.'
+ cycle: 'Zirkuläre Extension-Abhängigkeit erkannt: %cycle%.'
+ resolved: 'Extension "%extension%" hat %count% Abhängigkeits-Eintrag(e) aufgelöst.'
scheduler:
- cron_invalid: 'Paket "%package%" deklariert einen ungültigen Scheduler-Cron-Ausdruck.'
- source_invalid: 'Paket-Scheduler-Task "%task%" aus "%package%" muss den Paketnamen als Source verwenden, nicht "%source%".'
- trusted_blocked: 'Paket-Scheduler-Task "%task%" aus "%package%" darf keine vertrauenswürdige Ausführung anfordern.'
+ cron_invalid: 'Extension "%extension%" deklariert einen ungültigen Scheduler-Cron-Ausdruck.'
+ source_invalid: 'Extension-Scheduler-Task "%task%" aus "%extension%" muss den Extensionnamen als Source verwenden, nicht "%source%".'
+ trusted_blocked: 'Extension-Scheduler-Task "%task%" aus "%extension%" darf keine vertrauenswürdige Ausführung anfordern.'
translation:
- aggregate_completed: 'Translation-Aggregation hat %files% Datei(en) für %locales% Locale(s) aus Core und %packages% aktiven Paket(en) zusammengeführt.'
+ aggregate_completed: 'Translation-Aggregation hat %files% Datei(en) für %locales% Locale(s) aus Core und %extensions% aktiven Extension(en) zusammengeführt.'
aggregate_failed: 'Translation-Aggregation konnte "%path%" nicht schreiben.'
statistics:
record_failed: 'Zugriffsstatistiken konnten den aktuellen Request nicht aufzeichnen.'
@@ -106,12 +117,34 @@ message:
snapshot_store_failed: 'Speicherung des Zugriffsstatistik-Snapshots ist fehlgeschlagen.'
cleanup_failed: 'Aufbewahrungs-Cleanup der Zugriffsstatistiken ist fehlgeschlagen.'
trace_id_invalid: 'Zugriffsstatistiken haben eine ungültige %label% erhalten.'
+ geoip:
+ download:
+ missing_license_key: 'Der GeoIP2-Datenbankdownload benötigt einen MaxMind-License-Key.'
+ invalid_license_key: 'MaxMind hat den GeoIP2-Datenbankdownload abgelehnt. Prüfe den konfigurierten License-Key.'
+ server_unreachable: 'Der MaxMind-Downloadserver konnte nicht erreicht werden. Versuche es später erneut.'
+ failed: 'Der GeoIP2-Datenbankdownload ist fehlgeschlagen. Prüfe den Diagnosekontext für den HTTP-Status.'
+ archive_invalid: 'Das heruntergeladene GeoIP2-Archiv konnte nicht entpackt werden.'
+ database_missing: 'Das heruntergeladene GeoIP2-Archiv enthielt keine Datenbankdatei.'
+ database_invalid: 'Die heruntergeladene GeoIP2-Datenbank konnte nicht validiert werden.'
+ write_failed: 'Die GeoIP2-Datenbank konnte nicht geschrieben werden.'
+ completed: 'GeoIP2-Datenbank wurde unter "%path%" aktualisiert.'
account_app_secret_rotation:
manual_owner_reset_required: 'APP_SECRET-Rotation-Recovery konnte nicht alle Owner-Reset-Links zustellen. Nutze die Emergency-Recovery-Datei, sofern vorhanden, oder führe "%command%" für die gelisteten Owner manuell aus.'
+ rate_limit:
+ exceeded: 'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.'
+ request_rejected: 'Die Anfrage konnte nicht akzeptiert werden.'
+ storage_degraded: 'Rate-Limit-Speicher war nicht verfügbar; die Anfrage wurde zugelassen.'
+ reset_degraded: 'Rate-Limit-Reset-Speicher war nicht verfügbar.'
+ auto_ban:
+ storage_degraded: 'Auto-Ban-Speicher war nicht verfügbar; Enforcement ist fail-open weitergelaufen.'
+ evaluation_degraded: 'Auto-Ban-Score-Auswertung war nicht verfügbar; die Anfrage wurde zugelassen.'
+ payload_invalid: 'Auto-Ban-State enthielt eine ungültige Payload und wurde ignoriert.'
+ reset_released: 'Auto-Ban wurde gelöst.'
+ alert_delivery_degraded: 'Auto-Ban-Owner-Alert-Zustellung war nicht verfügbar.'
event:
hook:
invalid: 'Event-Hook "%event%" ist keine gültige öffentliche Hook-Definition.'
- unregistered: 'Event-Hook "%event%" ist nicht als öffentlicher Paket-Hook registriert.'
+ unregistered: 'Event-Hook "%event%" ist nicht als öffentlicher Extension-Hook registriert.'
listener_failed: 'Event-Hook "%event%" ist beim Benachrichtigen eines Listeners fehlgeschlagen.'
view_context:
summary: 'Erweitert den universellen Twig-View-Kontext vor dem Rendering.'
@@ -122,19 +155,19 @@ message:
navigation_builder:
summary: 'Erweitert Navigationspunkte, bevor Baumhierarchie, Sortierung und Render-Slices aufgelöst werden.'
static_view_injection_registry:
- summary: 'Ergänzt statische Paket-View-Injections, bevor Routen und Menüeinträge aufgelöst werden.'
+ summary: 'Ergänzt statische Extension-View-Injections, bevor Routen und Menüeinträge aufgelöst werden.'
dynamic_view_injection_registry:
- summary: 'Ergänzt content-bewusste dynamische Paket-View-Injections, bevor Content-Slots und Varianten gerendert werden.'
+ summary: 'Ergänzt content-bewusste dynamische Extension-View-Injections, bevor Content-Slots und Varianten gerendert werden.'
response_headers:
summary: 'Passt HTTP-Response-Header an, bevor die Response gesendet wird.'
output_generated:
summary: 'Passt generierten HTML-Output nach dem Rendering und vor dem Senden der Response an.'
- package_asset_sync_started:
- summary: 'Beobachtet die aktiven Pakete, bevor Paket-Assets synchronisiert werden.'
- package_asset_registry_build:
- summary: 'Ergänzt Paket-Asset-Registry-Beiträge, bevor CSS- und JavaScript-Registries geschrieben werden.'
- package_asset_sync_completed:
- summary: 'Beobachtet Paket-Asset-Sync-Metriken nach dem Schreiben der Registries.'
+ extension_asset_sync_started:
+ summary: 'Beobachtet die aktiven Extensions, bevor Extension-Assets synchronisiert werden.'
+ extension_asset_registry_build:
+ summary: 'Ergänzt Extension-Asset-Registry-Beiträge, bevor CSS- und JavaScript-Registries geschrieben werden.'
+ extension_asset_sync_completed:
+ summary: 'Beobachtet Extension-Asset-Sync-Metriken nach dem Schreiben der Registries.'
view:
dynamic_injection:
render_failed: 'Dynamische View-Injection "%uid%" ist beim Rendern von "%template%" fehlgeschlagen.'
diff --git a/translations/languages/de/setup.yaml b/translations/languages/de/setup.yaml
index a24b8f79..4579859e 100644
--- a/translations/languages/de/setup.yaml
+++ b/translations/languages/de/setup.yaml
@@ -211,6 +211,10 @@ setup:
label: 'Website-URL'
registration_mode:
label: 'Benutzerregistrierung'
+ options:
+ disabled: 'Deaktiviert'
+ admin_approval: 'Admin-Freigabe'
+ auto_approval: 'Automatische Freigabe'
username_change_enabled:
label: 'Benutzernamenänderungen erlauben'
route_prefixes_enabled:
@@ -279,7 +283,7 @@ setup:
url: 'Gib eine gültige URL ein.'
review:
title: 'Setup prüfen'
- text: 'Setup schreibt jetzt den Environment-Override, führt Migrationen aus, legt Standarddaten an, startet Paket-Discovery, baut Assets neu und sperrt die Setup-Route erst nach Erfolg.'
+ text: 'Setup schreibt jetzt den Environment-Override, führt Migrationen aus, legt Standarddaten an, startet Extension-Discovery, baut Assets neu und sperrt die Setup-Route erst nach Erfolg.'
none: 'Keine'
enabled: 'Aktiviert'
disabled: 'Deaktiviert'
@@ -316,6 +320,6 @@ setup:
seed_initial_content: 'Startinhalt anlegen'
mark_setup_completed: 'Setup als abgeschlossen markieren'
clear_cache: 'Cache leeren'
- run_package_discovery: 'Paket-Discovery ausführen'
+ run_extension_discovery: 'Extension-Discovery ausführen'
run_asset_rebuild: 'Assets neu bauen'
run_mercure_health: 'Mercure-Hub prüfen'
diff --git a/translations/languages/de/ui.yaml b/translations/languages/de/ui.yaml
index e35dc84b..f5a25ba4 100644
--- a/translations/languages/de/ui.yaml
+++ b/translations/languages/de/ui.yaml
@@ -226,6 +226,9 @@ ui:
403:
title: 'Zugriff verweigert'
message: 'Diese Route ist geschützt oder nicht für direkten Zugriff verfügbar.'
+ 400:
+ title: 'Ungültige Anfrage'
+ message: 'Die Anfrage konnte nicht akzeptiert werden.'
404:
title: 'Seite nicht gefunden'
message: 'Die angefragte Seite wurde nicht gefunden oder ist nicht veröffentlicht.'
diff --git a/translations/languages/en/admin.yaml b/translations/languages/en/admin.yaml
index c9bf50b3..4dc44329 100644
--- a/translations/languages/en/admin.yaml
+++ b/translations/languages/en/admin.yaml
@@ -16,7 +16,7 @@ admin:
users: 'Users'
user_groups: 'ACL groups'
user_reviews: 'Reviews'
- packages: 'Packages'
+ extensions: 'Extensions'
themes: 'Themes'
scheduler: 'Scheduler'
backups: 'Backups'
@@ -29,32 +29,36 @@ admin:
user_settings: 'Users'
mail_settings: 'Mail'
security_settings: 'Security'
- package_settings: 'Packages'
+ logging_settings: 'Logs'
+ extension_settings: 'Extensions'
statistics_settings: 'Statistics'
api_settings: 'API'
+ acl_settings: 'ACL'
scheduler_settings: 'Scheduler'
system_info: 'System information'
actions:
- package_discovery:
+ extension_discovery:
label: 'Update registry'
asset_rebuild:
label: 'Rebuild assets'
cache_clear:
label: 'Clear cache'
+ geoip_database_update:
+ label: 'Download GeoIP2 database'
dashboard:
title: 'Admin dashboard'
foundation_title: 'Backend foundation'
foundation_text: 'Administrative routes are now resolved through the native backend router. Functional pages can attach here once authentication, navigation, and access checks are ready.'
- packages:
- title: 'Package management'
- foundation_title: 'Package view foundation'
- foundation_text: 'Package management will attach here once install, update, activation, and cleanup workflows are wired.'
- registry_title: 'Registered packages'
- registry_text: 'Packages discovered in the registry are listed here. Lifecycle actions will attach once operation flows are wired.'
- empty: 'No packages are registered yet.'
+ extensions:
+ title: 'Extension management'
+ foundation_title: 'Extension view foundation'
+ foundation_text: 'Extension management will attach here once install, update, activation, and cleanup workflows are wired.'
+ registry_title: 'Registered extensions'
+ registry_text: 'Extensions discovered in the registry are listed here. Lifecycle actions will attach once operation flows are wired.'
+ empty: 'No extensions are registered yet.'
unknown_value: 'Unknown'
table:
- package: 'Package'
+ extension: 'Extension'
status: 'Status'
scopes: 'Scopes'
version: 'Version'
@@ -78,59 +82,59 @@ admin:
none: 'No actions'
install:
label: 'Install'
- title: 'Install package'
- text: 'Upload a package ZIP. The package manifest is verified first and the final installation waits for confirmation in the ActionLog.'
- file: 'Package ZIP'
- submit: 'Verify package'
+ title: 'Install extension'
+ text: 'Upload an extension ZIP. The extension manifest is verified first and the final installation waits for confirmation in the ActionLog.'
+ file: 'Extension ZIP'
+ submit: 'Verify extension'
close: 'Close'
meta:
author: 'By %author%'
detail:
- overview_title: 'Package overview'
+ overview_title: 'Extension overview'
immutable: 'Immutable'
description: 'Description'
author: 'Author'
license: 'License'
dependencies: 'Dependencies'
- dependencies_empty: 'No package dependencies'
+ dependencies_empty: 'No extension dependencies'
homepage: 'Homepage'
source: 'Source'
readme_title: 'README'
lifecycle:
review_title: 'Review changes'
- immutable: 'This system package is immutable and cannot change lifecycle state.'
+ immutable: 'This system extension is immutable and cannot change lifecycle state.'
unavailable: 'This lifecycle action is not available.'
- no_changes: 'This action does not need to change package state.'
+ no_changes: 'This action does not need to change extension state.'
cancel: 'Cancel'
table:
action: 'Action'
activate:
label: 'Activate'
- title: 'Activate %package%'
- text: 'Review dependency and conflict changes before activating this package.'
- submit: 'Activate package'
+ title: 'Activate %extension%'
+ text: 'Review dependency and conflict changes before activating this extension.'
+ submit: 'Activate extension'
deactivate:
label: 'Deactivate'
- title: 'Deactivate %package%'
- text: 'Review the planned state change before deactivating this package.'
- submit: 'Deactivate package'
+ title: 'Deactivate %extension%'
+ text: 'Review the planned state change before deactivating this extension.'
+ submit: 'Deactivate extension'
reset_fault:
label: 'Reset fault'
- title: 'Reset fault for %package%'
- text: 'The package will be validated again and returned to inactive if recovery succeeds.'
+ title: 'Reset fault for %extension%'
+ text: 'The extension will be validated again and returned to inactive if recovery succeeds.'
submit: 'Reset fault'
purge:
label: 'Delete data'
- title: 'Delete data for %package%'
- text: 'Review package-owned data cleanup before resetting this package registration.'
- warning: 'This step is irreversible. Package-owned settings and future package-owned data cleanup targets will be deleted.'
+ title: 'Delete data for %extension%'
+ text: 'Review extension-owned data cleanup before resetting this extension registration.'
+ warning: 'This step is irreversible. Extension-owned settings and future extension-owned data cleanup targets will be deleted.'
submit: 'Delete data'
delete:
- label: 'Delete package'
- title: 'Delete %package%'
- text: 'Review the filesystem removal before deleting this package from disk.'
- warning: 'This step is irreversible. The package files will be deleted and the registry row will stay as removed until data is deleted separately.'
- submit: 'Delete package'
+ label: 'Delete extension'
+ title: 'Delete %extension%'
+ text: 'Review the filesystem removal before deleting this extension from disk.'
+ warning: 'This step is irreversible. The extension files will be deleted and the registry row will stay as removed until data is deleted separately.'
+ submit: 'Delete extension'
themes:
title: 'Theme management'
foundation_title: 'Theme management foundation'
@@ -145,7 +149,7 @@ admin:
text: 'Administration and editor shell themes plus the native system fallback are listed here.'
type:
system: 'System'
- package: 'Package'
+ extension: 'Extension'
quick:
use: 'Use'
active: 'Active'
@@ -472,9 +476,9 @@ admin:
ui_alert_inbox_cleanup:
label: 'UI alert inbox cleanup'
description: 'Removes queued UI alerts after their delivery retention expires.'
- package_discovery:
- label: 'Package discovery'
- description: 'Discovers packages and updates the package registry.'
+ extension_discovery:
+ label: 'Extension discovery'
+ description: 'Discovers extensions and updates the extension registry.'
statistics_snapshot:
label: 'Statistics snapshot'
description: 'Refreshes the stored access statistics snapshot when statistics are enabled.'
@@ -484,6 +488,9 @@ admin:
mercure_health:
label: 'Mercure health'
description: 'Checks the Mercure hub and starts the local hub when supported.'
+ geoip2_database_update:
+ label: 'GeoIP2 database update'
+ description: 'Downloads and atomically replaces the local MaxMind GeoIP2 City database when a license key is configured.'
table:
job: 'Job'
source: 'Source'
@@ -511,17 +518,17 @@ admin:
failure_count: 'Consecutive failures'
run_url: 'Direct run URL'
run_now_inactive: 'Activate this job before running it manually.'
- package_action_queue_warning: 'This package job can run an ActionQueue and is controlled by the package ActionQueue scheduler setting.'
+ extension_action_queue_warning: 'This extension job can run an ActionQueue and is controlled by the extension ActionQueue scheduler setting.'
form:
title: 'Job settings'
enabled: 'Run this job automatically'
cron_expression: 'Cron expression'
- confirm_package_action_queue: 'I understand that this package job can run an ActionQueue.'
+ confirm_extension_action_queue: 'I understand that this extension job can run an ActionQueue.'
saved: 'Scheduler job saved.'
errors:
invalid_csrf: 'The scheduler form expired. Please try again.'
cron_invalid: 'Enter a valid cron expression.'
- package_action_queue_confirmation_required: 'Confirm the package ActionQueue warning before enabling this job.'
+ extension_action_queue_confirmation_required: 'Confirm the extension ActionQueue warning before enabling this job.'
runs:
title: 'Recent runs'
empty: 'This job has not run yet.'
@@ -590,22 +597,24 @@ admin:
logs:
title: 'Logs'
foundation_title: 'Log and audit foundation'
- foundation_text: 'Operational logs, audit views, package diagnostics, and security-relevant events will attach here.'
+ foundation_text: 'Operational logs, audit views, extension diagnostics, and security-relevant events will attach here.'
empty_value: 'n/a'
sources:
+ label: 'Log sources'
application: 'Application'
message: 'Messages'
audit: 'Audit'
access: 'Access'
+ security_signal: 'Security signals'
filters:
title: 'Filters'
source: 'Log'
level: 'Level'
- any_level: 'Any level'
search: 'Search'
window: 'Time window'
match: 'Match'
audit_action: 'Audit action'
+ signal_reason: 'Signal reason'
per_page: 'Rows'
limit: 'Limit'
contains: 'Contains'
@@ -620,8 +629,6 @@ admin:
entries:
title: 'Entries'
empty: 'No log entries match the current filters.'
- no_files: 'No log file exists for this selection yet.'
- files: 'Reading: %files%'
open: 'Details'
page_summary: 'Page %page% of %pages% · %total% entries'
previous: 'Previous'
@@ -637,6 +644,8 @@ admin:
location: 'Location'
user: 'User'
action: 'Action'
+ security_signal: 'Signal'
+ subject: 'Subject'
channel: 'Channel'
context: 'Context'
details: 'Details'
@@ -645,6 +654,31 @@ admin:
context: 'Context'
no_context: 'No structured context is available.'
raw: 'Raw line'
+ auto_bans:
+ active:
+ title: 'Active auto-bans'
+ empty: 'No active auto-bans.'
+ open: 'Review'
+ open_list: 'Open active auto-bans'
+ disabled: 'Auto-ban enforcement is disabled. Existing TTL states are retained until expiry but are not enforced.'
+ columns:
+ subject: 'Subject'
+ created_at: 'Created'
+ expires_at: 'Expires'
+ ttl: 'TTL seconds'
+ details: 'Details'
+ detail:
+ title: 'Auto-ban detail'
+ signals: 'Related security signals'
+ no_signals: 'No retained signals are available for this active ban.'
+ trigger_country: 'Trigger country'
+ trigger_continent: 'Trigger continent'
+ reset:
+ submit: 'Reset auto-ban'
+ saved: 'Auto-ban reset.'
+ failed: 'Auto-ban could not be reset.'
+ alerts:
+ triggered: 'Auto-ban created for %subject%.'
statistics:
title: 'Statistics'
access_title: 'Access statistics'
@@ -675,8 +709,12 @@ admin:
all: 'All stored events'
settings:
title: 'Settings'
+ acl:
+ title: 'ACL'
+ foundation_title: 'ACL matrix'
+ foundation_text: 'Owner-controlled feature permissions for administrative, editor, and future frontend surfaces.'
redirect_title: 'Settings sections'
- redirect_text: 'General settings are the first built-in settings section. Active packages with simple setting definitions appear under package settings.'
+ redirect_text: 'General settings are the first built-in settings section. Active extensions with simple setting definitions appear under extension settings.'
general:
title: 'General settings'
foundation_title: 'General configuration foundation'
@@ -696,23 +734,41 @@ admin:
security:
title: 'Security settings'
foundation_title: 'Security configuration foundation'
- foundation_text: 'Security, captcha, logging, and abuse-control settings will attach here.'
+ foundation_text: 'Security, captcha, passive-signal retention, and abuse-control settings will attach here.'
+ logging:
+ title: 'Log settings'
+ foundation_title: 'Database log projections'
+ foundation_text: 'Message, audit, and access logs keep their fixed rotating file logs separately. These settings control how long the searchable database copies stay available.'
statistics:
title: 'Statistics settings'
foundation_title: 'Statistics configuration'
- foundation_text: 'Access statistics recording and display can be disabled independently from raw access logging. Raw access logs remain available for operational security and are retained for 30 days.'
+ foundation_text: 'Access statistics recording, display, and GeoIP enrichment can be disabled independently from raw access logging. Raw access logs remain available for operational security and are retained for 30 days.'
+ geoip:
+ status:
+ title: 'GeoIP2 status'
+ provider: 'Provider'
+ state: 'State'
+ database_edition: 'Database edition'
+ database_build_date: 'Database build date'
+ failure_code: 'Failure code'
+ values:
+ ready: 'Ready'
+ disabled: 'Disabled'
+ unconfigured: 'Unconfigured'
+ unavailable: 'Unavailable'
+ unknown: 'Unknown'
api:
title: 'API settings'
foundation_title: 'API availability'
foundation_text: 'API access can be disabled for normal users while owners keep key management available for operational integrations such as scheduler activation.'
- packages:
- title: 'Package settings'
- foundation_title: 'Packages with settings'
- empty: 'No active package exposes settings yet.'
- package:
- title: '%package% settings'
- foundation_title: 'Package settings'
- empty: 'This package does not expose configurable settings.'
+ extensions:
+ title: 'Extension settings'
+ foundation_title: 'Extensions with settings'
+ empty: 'No active extension exposes settings yet.'
+ extension:
+ title: '%extension% settings'
+ foundation_title: 'Extension settings'
+ empty: 'This extension does not expose configurable settings.'
options: '%count% option(s)'
validation: '%count% validation rule(s)'
scheduler:
@@ -738,6 +794,153 @@ admin:
required: 'This field is required.'
save_failed: 'The settings could not be saved.'
default_acl_group_unavailable: 'Enter an existing ACL group with minimum role User or lower, or leave the field empty.'
+ fields:
+ captcha_enabled:
+ label: 'Enable captcha'
+ captcha_provider:
+ label: 'Captcha provider'
+ captcha_preview:
+ label: 'Captcha preview'
+ rate_limit_mode:
+ label: 'Rate limiting'
+ auto_ban_enabled:
+ label: 'Enable auto-ban'
+ help: 'Temporarily blocks repeated suspicious Visitor/IP activity. Trusted users remain exempt.'
+ auto_ban_trusted_access_level:
+ label: 'Trusted user level'
+ help: 'Registered users at or above this access level are never auto-banned.'
+ auto_ban_score_threshold:
+ label: 'Auto-ban score threshold'
+ help: 'Score required within one hour before a new Visitor ban can be created. IP-only bans use a higher internal threshold.'
+ auto_ban_new_ban_owner_alerts:
+ label: 'Enable alerts for newly decided auto-bans'
+ help: 'Queues a hidden warning for Owner accounts when a new active auto-ban is created.'
+ audit_enabled:
+ label: 'Enable audit logging'
+ audit_events:
+ label: 'Audit event categories'
+ security_signal_retention_days:
+ label: 'Security signal retention'
+ help: 'Retention for passive security signals in days. Values above 30 days are not allowed because signals can contain request-derived identifiers.'
+ security_probe_path_patterns:
+ label: 'Suspicious probe path patterns'
+ help: 'One regular expression per line. Quoted CSV imports are accepted. Invalid or empty lists fall back to the protected defaults.'
+ options:
+ captcha:
+ none: 'No captcha provider'
+ rate_limit_mode:
+ off: 'Off'
+ standard: 'Standard'
+ strict: 'Strict'
+ panic: 'Panic'
+ access_level:
+ user: 'User'
+ moderator: 'Moderator'
+ author: 'Author'
+ publisher: 'Publisher'
+ curator: 'Curator'
+ manager: 'Manager'
+ director: 'Director'
+ admin: 'Admin'
+ owner: 'Owner'
+ audit:
+ authentication: 'Authentication events'
+ backend_actions: 'Backend maintenance actions'
+ operations: 'Operations maintenance actions'
+ extensions: 'Extension lifecycle actions'
+ settings: 'Settings changes'
+ other: 'Other future audit events'
+ acl:
+ actions:
+ save: 'Save ACL matrix'
+ categories:
+ diagnostics: 'Diagnostics'
+ operations: 'Operations'
+ extensions: 'Extensions'
+ extension_settings: 'Extension settings'
+ settings: 'Settings'
+ system: 'System'
+ users: 'Users'
+ empty: 'No feature flags are registered for this surface yet.'
+ groups:
+ empty: 'No ACL groups can grant this surface.'
+ non_configurable: 'Read-only'
+ read_only_action: 'You may review this action, but mutation is not allowed by the current ACL policy.'
+ states:
+ inherit: 'Inherit'
+ denied: 'Denied'
+ visible: 'Visible'
+ mutable: 'Mutable'
+ surfaces:
+ label: 'ACL surfaces'
+ admin: 'Admin'
+ admin_text: 'Administrative feature flags. ACL groups can grant permissions only to users that already pass the Admin surface gate.'
+ editor: 'Editor'
+ editor_text: 'Prepared for future editor feature flags. Editor providers can register rows here later.'
+ frontend: 'Frontend'
+ frontend_text: 'Reserved for explicitly designed future frontend features such as inline editing.'
+ table:
+ admin_state: 'Admin'
+ default: 'Default'
+ feature: 'Feature'
+ groups: 'ACL group grants'
+ features:
+ admin_settings_security:
+ label: 'Security settings'
+ description: 'Security policy, audit categories, signal retention, and suspicious probe patterns.'
+ admin_settings_logging:
+ label: 'Log retention settings'
+ description: 'Database lookup retention for message, audit, and access log projections.'
+ admin_settings_statistics:
+ label: 'Statistics settings'
+ description: 'Access statistics recording and display settings.'
+ admin_settings_statistics_geoip:
+ label: 'GeoIP settings'
+ description: 'GeoIP enablement, local database path, protected MaxMind license key, and status.'
+ admin_settings_api:
+ label: 'API settings'
+ description: 'Global API availability and CORS expansion settings.'
+ admin_settings_scheduler:
+ label: 'Scheduler settings'
+ description: 'Scheduler enablement, web triggering, GET authentication, and extension action queues.'
+ admin_settings_extensions:
+ label: 'Extension settings'
+ description: 'Core extension-update settings and extension-owned settings pages.'
+ admin_settings_extension:
+ description: 'Extension-owned settings page for an active extension.'
+ admin_logs:
+ label: 'Logs'
+ description: 'Administrative log review surfaces.'
+ admin_extensions:
+ label: 'Extensions and themes'
+ description: 'Extension and theme management, including install, activation, deactivation, repair, deletion, and purge workflows.'
+ admin_extensions_self_update:
+ label: 'System extension self-update'
+ description: 'Owner-only system extension self-update workflows.'
+ admin_backup_restore:
+ label: 'Backup, export, and restore'
+ description: 'Full-data backup, export, download, and restore workflows.'
+ admin_operations:
+ label: 'Operations'
+ description: 'Live operation inspection and operational status surfaces.'
+ admin_actions_maintenance:
+ label: 'Maintenance actions'
+ description: 'Manual maintenance actions such as cache clear and asset rebuild.'
+ admin_scheduler:
+ label: 'Scheduler'
+ description: 'Scheduler task inspection and run controls.'
+ admin_users:
+ label: 'Users'
+ description: 'User administration and account management.'
+ admin_users_acl:
+ label: 'User ACL groups'
+ description: 'ACL group administration and membership review.'
+ admin_users_review:
+ label: 'User reviews'
+ description: 'User invitation, registration, and review queues.'
+ admin_support:
+ label: 'Support bundles'
+ description: 'Diagnostic or support-bundle generation and download.'
fields:
site_title:
label: 'Site title'
@@ -786,10 +989,50 @@ admin:
label: 'Captcha provider'
captcha_preview:
label: 'Captcha preview'
+ rate_limit_mode:
+ label: 'Rate limiting'
+ auto_ban_enabled:
+ label: 'Enable auto-ban'
+ help: 'Temporarily blocks repeated suspicious Visitor/IP activity. Trusted users remain exempt.'
+ auto_ban_trusted_access_level:
+ label: 'Trusted user level'
+ help: 'Registered users at or above this access level are never auto-banned.'
+ auto_ban_score_threshold:
+ label: 'Auto-ban score threshold'
+ help: 'Score required within one hour before a new Visitor ban can be created. IP-only bans use a higher internal threshold.'
+ auto_ban_new_ban_owner_alerts:
+ label: 'Enable alerts for newly decided auto-bans'
+ help: 'Queues a hidden warning for Owner accounts when a new active auto-ban is created.'
audit_enabled:
label: 'Enable audit logging'
audit_events:
label: 'Audit event categories'
+ message_log_retention_days:
+ label: 'Message log database retention'
+ help: 'Database lookup copy retention in days. File logs keep their fixed 30-day rotation separately.'
+ audit_log_retention_days:
+ label: 'Audit log database retention'
+ help: 'Database lookup copy retention in days. Values above 30 days are not allowed because audit data can contain request-derived identifiers.'
+ access_log_retention_days:
+ label: 'Access log database retention'
+ help: 'Database lookup copy retention in days. Values above 30 days are not allowed because access logs can contain IP-derived data.'
+ security_signal_retention_days:
+ label: 'Security signal retention'
+ help: 'Retention for passive security signals in days. Values above 30 days are not allowed because signals can contain request-derived identifiers.'
+ security_probe_path_patterns:
+ label: 'Suspicious probe path patterns'
+ help: 'One regular expression per line. Quoted CSV imports are accepted. Invalid or empty lists fall back to the protected defaults.'
+ geoip_enabled:
+ label: 'Enable GeoIP lookups'
+ help: 'When disabled or unavailable, logs and statistics keep normalized n/a location values.'
+ geoip_license_link:
+ label: 'Get a free GeoLite2 license key from MaxMind'
+ geoip_database_path:
+ label: 'MaxMind database path'
+ help: 'Project-relative path to the local .mmdb database. Downloads are written atomically to this path.'
+ geoip_license_key:
+ label: 'MaxMind license key'
+ help: 'Stored as sensitive configuration. Saving a key enables the GeoIP2 database download action on this page.'
statistics_enabled:
label: 'Enable access statistics'
statistics_respect_dnt:
@@ -803,34 +1046,44 @@ admin:
api_cors_allowed_origins:
label: 'Allowed API CORS origins'
help: 'JSON array of exact origins such as ["https://client.example"]. The wildcard "*" should only be used intentionally.'
- package_update_interval:
+ extension_update_interval:
label: 'Update check interval'
- package_auto_updates:
- label: 'Allow automatic package updates'
+ extension_auto_updates:
+ label: 'Allow automatic extension updates'
scheduler_enabled:
label: 'Enable scheduler'
scheduler_get_auth_enabled:
label: 'Allow GET authentication for /cron/run'
help: 'Bearer authentication remains preferred. Enable GET authentication only for hosting schedulers that cannot send headers.'
- scheduler_package_action_queues_enabled:
- label: 'Allow package scheduler action queues'
- help: 'Package action queues may run filesystem or process operations. Core scheduler queues are trusted separately.'
+ scheduler_extension_action_queues_enabled:
+ label: 'Allow extension scheduler action queues'
+ help: 'Extension action queues may run filesystem or process operations. Core scheduler queues are trusted separately.'
scheduler_web_trigger_enabled:
label: 'Run scheduler from web traffic'
help: 'Starts a detached scheduler run after eligible page requests at most once per minute. Use a real cron job when available.'
options:
dashboard:
system_status: 'System status'
- packages: 'Package status'
+ extensions: 'Extension status'
recent_activity: 'Recent activity'
setup_warnings: 'Setup warnings'
captcha:
none: 'No captcha provider'
+ rate_limit_mode:
+ off: 'Off'
+ standard: 'Standard'
+ strict: 'Strict'
+ panic: 'Panic'
+ access_level:
+ manager: 'Manager'
+ director: 'Director'
+ admin: 'Admin'
+ owner: 'Owner'
audit:
authentication: 'Authentication events'
backend_actions: 'Backend maintenance actions'
operations: 'Operations maintenance actions'
- packages: 'Package lifecycle actions'
+ extensions: 'Extension lifecycle actions'
settings: 'Settings changes'
other: 'Other future audit events'
registration:
diff --git a/translations/languages/en/editor.yaml b/translations/languages/en/editor.yaml
index ab322aab..4c645aec 100644
--- a/translations/languages/en/editor.yaml
+++ b/translations/languages/en/editor.yaml
@@ -10,6 +10,6 @@ editor:
title: 'Editor dashboard'
status: 'Content workspace'
foundation_title: 'Editor foundation'
- foundation_text: 'Content routes can attach to this shell once editor workflows, schema tools, and package contributions are wired.'
+ foundation_text: 'Content routes can attach to this shell once editor workflows, schema tools, and extension contributions are wired.'
message:
title: 'Editor route'
diff --git a/translations/languages/en/message.yaml b/translations/languages/en/message.yaml
index d88650cc..01137d17 100644
--- a/translations/languages/en/message.yaml
+++ b/translations/languages/en/message.yaml
@@ -8,97 +8,108 @@ message:
unknown_key: 'Manifest key "%key%" is not supported here.'
parsed: 'Manifest was parsed.'
validated: 'Manifest was validated.'
- package:
+ extension:
identifier:
- invalid: 'Package identifier "%identifier%" contains unsupported characters.'
- manifest_unreadable: 'Package manifest "%path%" cannot be read.'
- required_file_missing: 'Required package file "%path%" is missing.'
- required_directory_missing: 'Required package directory "%path%" is missing.'
- file_unreadable: 'Package file "%path%" cannot be read.'
+ invalid: 'Extension identifier "%identifier%" contains unsupported characters.'
+ manifest_unreadable: 'Extension manifest "%path%" cannot be read.'
+ required_file_missing: 'Required extension file "%path%" is missing.'
+ required_directory_missing: 'Required extension directory "%path%" is missing.'
+ file_unreadable: 'Extension file "%path%" cannot be read.'
php_syntax_error: 'PHP syntax check failed for "%path%".'
- php_namespace_invalid: 'PHP source "%path%" must use package namespace "%expected_namespace%".'
+ php_namespace_invalid: 'PHP source "%path%" must use extension namespace "%expected_namespace%".'
twig_syntax_error: 'Twig syntax check failed for "%path%".'
- translation_fallback_missing: 'Package translation sources for "%package%" must include at least one fallback catalogue under "languages/%locale%".'
- translation_namespace_invalid: 'Package translation file "%path%" must define keys below "pkg.%package%".'
+ translation_fallback_missing: 'Extension translation sources for "%extension%" must include at least one fallback catalogue under "languages/%locale%".'
+ translation_namespace_invalid: 'Extension translation file "%path%" must define keys below "ext.%extension%".'
json_syntax_error: 'JSON syntax check failed for "%path%".'
yaml_syntax_error: 'YAML syntax check failed for "%path%".'
css_syntax_error: 'CSS syntax check failed for "%path%".'
- css_namespace_invalid: 'CSS class "%class%" in "%path%" must use the package-owned prefix "%expected_prefix%".'
+ css_namespace_invalid: 'CSS class "%class%" in "%path%" must use the extension-owned prefix "%expected_prefix%".'
javascript_syntax_error: 'JavaScript syntax check failed for "%path%".'
- scope_invalid: 'Package scope "%scope%" is not supported.'
- template_path_invalid: 'Package template "%path%" is not allowed by root, provider, or macro namespace rules for package scope "%scope%".'
- template_reference_invalid: 'Package template "%path%" must not reference template "%reference%" outside its own template scope.'
+ scope_invalid: 'Extension scope "%scope%" is not supported.'
+ template_path_invalid: 'Extension template "%path%" is not allowed by root, provider, or macro namespace rules for extension scope "%scope%".'
+ template_reference_invalid: 'Extension template "%path%" must not reference template "%reference%" outside its own template scope.'
policy:
- blocked_path: 'Package path "%path%" is blocked by package policy (%reason%).'
- warned_path: 'Package path "%path%" should be reviewed by package policy (%reason%).'
- blocked_php_capability: 'Package PHP file "%path%" uses blocked capability "%capability%" (%reason%). Use a documented extension point instead.'
- copy_source_missing: 'Package copy source "%path%" is missing.'
- copy_source_symlink: 'Package copy source "%path%" must not be a symlink.'
- asset_sync_completed: 'Package asset sync mirrored %assets% asset(s) from %packages% package(s).'
- asset_sync_failed: 'Package asset sync failed: %message%'
- asset_rebuild_queued: 'Package asset rebuild was queued by "%trigger%".'
- asset_rebuild_queue_failed: 'Package asset rebuild could not be queued by "%trigger%".'
+ blocked_path: 'Extension path "%path%" is blocked by extension policy (%reason%).'
+ warned_path: 'Extension path "%path%" should be reviewed by extension policy (%reason%).'
+ blocked_php_capability: 'Extension PHP file "%path%" uses blocked capability "%capability%" (%reason%). Use a documented extension point instead.'
+ copy_source_missing: 'Extension copy source "%path%" is missing.'
+ copy_source_symlink: 'Extension copy source "%path%" must not be a symlink.'
+ asset_sync_completed: 'Extension asset sync mirrored %assets% asset(s) from %extensions% extension(s).'
+ asset_sync_failed: 'Extension asset sync failed: %message%'
+ asset_rebuild_queued: 'Extension asset rebuild was queued by "%trigger%".'
+ asset_rebuild_queue_failed: 'Extension asset rebuild could not be queued by "%trigger%".'
asset:
- contribution_package_invalid: 'Package asset contribution requires a package identifier.'
- contribution_type_invalid: 'Package asset contribution type "%type%" is not supported.'
- contribution_path_invalid: 'Package asset path "%path%" must be project-relative.'
- contribution_path_traversal: 'Package asset path "%path%" must not traverse parent directories.'
- discovery_queued: 'Package discovery was queued by "%trigger%".'
- discovery_queue_failed: 'Package discovery could not be queued by "%trigger%".'
- discovery_completed: 'Package discovery found %count% candidate(s).'
- validation_completed: 'Package "%package%" passed validation.'
- copy_plan_created: 'Package copy plan contains %count% file(s).'
+ contribution_extension_invalid: 'Extension asset contribution requires an extension identifier.'
+ contribution_type_invalid: 'Extension asset contribution type "%type%" is not supported.'
+ contribution_path_invalid: 'Extension asset path "%path%" must be project-relative.'
+ contribution_path_traversal: 'Extension asset path "%path%" must not traverse parent directories.'
+ database:
+ contribution_invalid: 'Extension database contribution is invalid (%reason%).'
+ sync_completed: 'Extension "%extension%" database sync created %count% table(s).'
+ purge_completed: 'Extension "%extension%" database purge dropped %count% table(s).'
+ content_schema:
+ contribution_invalid: 'Extension content schema contribution is invalid (%reason%).'
+ sync_completed: 'Extension "%extension%" content schema sync created or versioned %count% schema(s).'
+ purge_completed: 'Extension "%extension%" content schema purge deleted %count% schema(s).'
+ purge_retained: 'Extension "%extension%" content schema purge retained %count% referenced schema(s).'
+ content_archived: '%count% content item(s) using deactivated extension schemas were archived.'
+ discovery_queued: 'Extension discovery was queued by "%trigger%".'
+ discovery_queue_failed: 'Extension discovery could not be queued by "%trigger%".'
+ discovery_completed: 'Extension discovery found %count% candidate(s).'
+ validation_completed: 'Extension "%extension%" passed validation.'
+ copy_plan_created: 'Extension copy plan contains %count% file(s).'
registry:
- sync_completed: 'Package registry sync recorded %count% change(s).'
- registered: 'Package "%package%" was registered as inactive.'
- updated: 'Package "%package%" registry metadata was updated.'
- removed: 'Package "%package%" is missing on disk and was marked as removed.'
- faulty: 'Package "%package%" failed validation and was marked as faulty.'
+ sync_completed: 'Extension registry sync recorded %count% change(s).'
+ registered: 'Extension "%extension%" was registered as inactive.'
+ updated: 'Extension "%extension%" registry metadata was updated.'
+ removed: 'Extension "%extension%" is missing on disk and was marked as removed.'
+ faulty: 'Extension "%extension%" failed validation and was marked as faulty.'
install:
- upload_invalid: 'The package upload is missing, invalid, or not a ZIP file.'
- zip_invalid: 'The package ZIP could not be read safely.'
- root_invalid: 'The package ZIP must contain one package root with a .manifest file.'
- ready: 'Package "%package%" version %version% can now be installed. Continue?'
- overwrite: 'Package "%package%" already exists and will be deactivated if active, then overwritten before discovery runs.'
- version_blocked: 'Package "%package%" version %version% cannot replace registered version %installed_version%. Downgrades are blocked, and same-version replacements are only allowed for faulty or removed packages; remove and purge the existing package first if this replacement is intentional.'
- completed: 'Package "%package%" was installed.'
+ upload_invalid: 'The extension upload is missing, invalid, or not a ZIP file.'
+ zip_invalid: 'The extension ZIP could not be read safely.'
+ root_invalid: 'The extension ZIP must contain one extension root with a .manifest file.'
+ ready: 'Extension "%extension%" version %version% can now be installed. Continue?'
+ overwrite: 'Extension "%extension%" already exists and will be deactivated if active, then overwritten before discovery runs.'
+ version_blocked: 'Extension "%extension%" version %version% cannot replace registered version %installed_version%. Downgrades are blocked, and same-version replacements are only allowed for faulty or removed extensions; remove and purge the existing extension first if this replacement is intentional.'
+ completed: 'Extension "%extension%" was installed.'
lifecycle:
- not_found: 'Package "%package%" is not registered.'
- status_blocked: 'Package "%package%" cannot change lifecycle state while it is "%status%".'
- activated: 'Package "%package%" was activated.'
- deactivated: 'Package "%package%" was deactivated.'
- dependent_deactivated: 'Package "%package%" was deactivated because dependency "%dependency%" became unavailable.'
- cleanup_completed: 'Package "%package%" cleanup completed.'
- removed: 'Package "%package%" was removed.'
- purged: 'Package "%package%" registry entry was deleted.'
- fault_reset: 'Package "%package%" fault state was reset after validation.'
- runtime_failure: 'Package "%package%" was marked faulty after a runtime failure.'
- php_load_failed: 'Package "%package%" PHP loader failed and the package was marked faulty.'
- rolled_back: 'Package lifecycle changes were rolled back for %count% package(s).'
+ not_found: 'Extension "%extension%" is not registered.'
+ status_blocked: 'Extension "%extension%" cannot change lifecycle state while it is "%status%".'
+ single_active_conflict: 'Extensions "%extensions%" cannot be activated together because scope "%scope%" allows only one active extension.'
+ activated: 'Extension "%extension%" was activated.'
+ deactivated: 'Extension "%extension%" was deactivated.'
+ dependent_deactivated: 'Extension "%extension%" was deactivated because dependency "%dependency%" became unavailable.'
+ cleanup_completed: 'Extension "%extension%" cleanup completed.'
+ removed: 'Extension "%extension%" was removed.'
+ purged: 'Extension "%extension%" registry entry was deleted.'
+ fault_reset: 'Extension "%extension%" fault state was reset after validation.'
+ runtime_failure: 'Extension "%extension%" was marked faulty after a runtime failure.'
+ php_load_failed: 'Extension "%extension%" PHP loader failed and the extension was marked faulty.'
+ rolled_back: 'Extension lifecycle changes were rolled back for %count% extension(s).'
runtime:
- contribution_unsupported: 'Package "%package%" returned unsupported runtime contribution type "%type%".'
+ contribution_unsupported: 'Extension "%extension%" returned unsupported runtime contribution type "%type%".'
live:
- endpoint_path_invalid: 'Package "%package%" tried to register live endpoint path "%path%" outside its own /api/live/{package}/ namespace.'
- endpoint_handler_invalid: 'Package "%package%" tried to register live endpoint handler "%handler%" outside its own handler namespace.'
- endpoint_reserved: 'Package "%package%" cannot register live endpoints with reserved system slug "%slug%".'
+ endpoint_path_invalid: 'Extension "%extension%" tried to register live endpoint path "%path%" outside its own /api/live/{extension}/ namespace.'
+ endpoint_handler_invalid: 'Extension "%extension%" tried to register live endpoint handler "%handler%" outside its own handler namespace.'
+ endpoint_reserved: 'Extension "%extension%" cannot register live endpoints with reserved system slug "%slug%".'
setting:
- read_failed: 'Package setting "%package%:%key%" could not be read.'
- write_failed: 'Package setting "%package%:%key%" could not be written.'
- delete_failed: 'Package settings for "%package%" could not be deleted.'
- value_invalid: 'Package setting "%package%:%key%" contains an invalid stored value.'
+ read_failed: 'Extension setting "%extension%:%key%" could not be read.'
+ write_failed: 'Extension setting "%extension%:%key%" could not be written.'
+ delete_failed: 'Extension settings for "%extension%" could not be deleted.'
+ value_invalid: 'Extension setting "%extension%:%key%" contains an invalid stored value.'
dependency:
- missing: 'Package dependency "%package%" required by "%required_by%" is not installed.'
- invalid: 'Package "%package%" declares malformed package dependencies.'
- version_unsatisfied: 'Package dependency "%package%" requires at least version %required_version%; installed version is %installed_version%.'
- status_blocked: 'Package dependency "%package%" cannot be used while it is "%status%".'
- cycle: 'Circular package dependency detected: %cycle%.'
- resolved: 'Package "%package%" resolved %count% dependency item(s).'
+ missing: 'Extension dependency "%extension%" required by "%required_by%" is not installed.'
+ invalid: 'Extension "%extension%" declares malformed extension dependencies.'
+ version_unsatisfied: 'Extension dependency "%extension%" requires at least version %required_version%; installed version is %installed_version%.'
+ status_blocked: 'Extension dependency "%extension%" cannot be used while it is "%status%".'
+ cycle: 'Circular extension dependency detected: %cycle%.'
+ resolved: 'Extension "%extension%" resolved %count% dependency item(s).'
scheduler:
- cron_invalid: 'Package "%package%" declares an invalid scheduler cron expression.'
- source_invalid: 'Package scheduler task "%task%" from "%package%" must use the package name as source instead of "%source%".'
- trusted_blocked: 'Package scheduler task "%task%" from "%package%" must not request trusted execution.'
+ cron_invalid: 'Extension "%extension%" declares an invalid scheduler cron expression.'
+ source_invalid: 'Extension scheduler task "%task%" from "%extension%" must use the extension name as source instead of "%source%".'
+ trusted_blocked: 'Extension scheduler task "%task%" from "%extension%" must not request trusted execution.'
translation:
- aggregate_completed: 'Translation aggregation merged %files% file(s) for %locales% locale(s) from core and %packages% active package(s).'
+ aggregate_completed: 'Translation aggregation merged %files% file(s) for %locales% locale(s) from core and %extensions% active extension(s).'
aggregate_failed: 'Translation aggregation could not write "%path%".'
statistics:
record_failed: 'Access statistics could not record the current request.'
@@ -106,12 +117,34 @@ message:
snapshot_store_failed: 'Access statistics snapshot storage failed.'
cleanup_failed: 'Access statistics retention cleanup failed.'
trace_id_invalid: 'Access statistics received an invalid %label%.'
+ geoip:
+ download:
+ missing_license_key: 'GeoIP2 database download needs a MaxMind license key.'
+ invalid_license_key: 'MaxMind rejected the GeoIP2 database download. Check the configured license key.'
+ server_unreachable: 'The MaxMind download server could not be reached. Try again later.'
+ failed: 'The GeoIP2 database download failed. Check the diagnostic context for the HTTP status.'
+ archive_invalid: 'The downloaded GeoIP2 archive could not be extracted.'
+ database_missing: 'The downloaded GeoIP2 archive did not contain a database file.'
+ database_invalid: 'The downloaded GeoIP2 database could not be validated.'
+ write_failed: 'The GeoIP2 database could not be written.'
+ completed: 'GeoIP2 database was updated at "%path%".'
account_app_secret_rotation:
manual_owner_reset_required: 'APP_SECRET rotation recovery could not deliver every owner reset link. Use the emergency recovery file when available, or run "%command%" manually for the listed owners.'
+ rate_limit:
+ exceeded: 'Too many requests. Please wait a moment before trying again.'
+ request_rejected: 'The request could not be accepted.'
+ storage_degraded: 'Rate-limit storage was unavailable; the request was allowed.'
+ reset_degraded: 'Rate-limit reset storage was unavailable.'
+ auto_ban:
+ storage_degraded: 'Auto-ban storage was unavailable; enforcement failed open.'
+ evaluation_degraded: 'Auto-ban score evaluation was unavailable; the request was allowed.'
+ payload_invalid: 'Auto-ban state contained an invalid payload and was ignored.'
+ reset_released: 'Auto-ban released.'
+ alert_delivery_degraded: 'Auto-ban owner alert delivery was unavailable.'
event:
hook:
invalid: 'Event hook "%event%" is not a valid public hook definition.'
- unregistered: 'Event hook "%event%" is not registered as a public package hook.'
+ unregistered: 'Event hook "%event%" is not registered as a public extension hook.'
listener_failed: 'Event hook "%event%" failed while notifying a listener.'
view_context:
summary: 'Extend the universal Twig view context before rendering.'
@@ -122,19 +155,19 @@ message:
navigation_builder:
summary: 'Extend navigation items before tree hierarchy, sort order, and render slices are resolved.'
static_view_injection_registry:
- summary: 'Add static package view injections before route and menu entries are resolved.'
+ summary: 'Add static extension view injections before route and menu entries are resolved.'
dynamic_view_injection_registry:
- summary: 'Add content-aware dynamic package view injections before content slots and variants are rendered.'
+ summary: 'Add content-aware dynamic extension view injections before content slots and variants are rendered.'
response_headers:
summary: 'Adjust HTTP response headers before the response is sent.'
output_generated:
summary: 'Adjust generated HTML output after rendering and before the response is sent.'
- package_asset_sync_started:
- summary: 'Observe the active package set before package assets are synchronized.'
- package_asset_registry_build:
- summary: 'Add package asset registry contributions before CSS and JavaScript registries are written.'
- package_asset_sync_completed:
- summary: 'Observe package asset synchronization metrics after registries are written.'
+ extension_asset_sync_started:
+ summary: 'Observe the active extension set before extension assets are synchronized.'
+ extension_asset_registry_build:
+ summary: 'Add extension asset registry contributions before CSS and JavaScript registries are written.'
+ extension_asset_sync_completed:
+ summary: 'Observe extension asset synchronization metrics after registries are written.'
view:
dynamic_injection:
render_failed: 'Dynamic view injection "%uid%" failed while rendering "%template%".'
diff --git a/translations/languages/en/setup.yaml b/translations/languages/en/setup.yaml
index fedaf852..8b23f36c 100644
--- a/translations/languages/en/setup.yaml
+++ b/translations/languages/en/setup.yaml
@@ -211,6 +211,10 @@ setup:
label: 'Site URL'
registration_mode:
label: 'User registration'
+ options:
+ disabled: 'Disabled'
+ admin_approval: 'Admin approval'
+ auto_approval: 'Auto approval'
username_change_enabled:
label: 'Allow username changes'
route_prefixes_enabled:
@@ -279,7 +283,7 @@ setup:
url: 'Enter a valid URL.'
review:
title: 'Review setup'
- text: 'Setup will now write the environment override, apply migrations, seed defaults, run package discovery, rebuild assets, and lock the setup route only after success.'
+ text: 'Setup will now write the environment override, apply migrations, seed defaults, run extension discovery, rebuild assets, and lock the setup route only after success.'
none: 'None'
enabled: 'Enabled'
disabled: 'Disabled'
@@ -316,6 +320,6 @@ setup:
seed_initial_content: 'Seed initial content'
mark_setup_completed: 'Mark setup completed'
clear_cache: 'Clear cache'
- run_package_discovery: 'Run package discovery'
+ run_extension_discovery: 'Run extension discovery'
run_asset_rebuild: 'Rebuild assets'
run_mercure_health: 'Check Mercure hub'
diff --git a/translations/languages/en/ui.yaml b/translations/languages/en/ui.yaml
index 864c372e..bc4002b6 100644
--- a/translations/languages/en/ui.yaml
+++ b/translations/languages/en/ui.yaml
@@ -226,6 +226,9 @@ ui:
403:
title: 'Access denied'
message: 'This route is protected or not available for direct access.'
+ 400:
+ title: 'Bad request'
+ message: 'The request could not be accepted.'
404:
title: 'Page not found'
message: 'The requested page could not be found or is not published.'