Skip to content

Commit 8bb00ee

Browse files
committed
added attribute #[Requires]
1 parent d780fe8 commit 8bb00ee

File tree

11 files changed

+506
-4
lines changed

11 files changed

+506
-4
lines changed

src/Application/Attributes/CrossOrigin.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@
1313

1414

1515
#[Attribute(Attribute::TARGET_METHOD)]
16-
class CrossOrigin
16+
class CrossOrigin // replaced by Requires(sameOrigin: false)
1717
{
1818
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Nette\Application\Attributes;
6+
7+
use Attribute;
8+
9+
10+
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
11+
class Requires
12+
{
13+
public ?array $methods = null;
14+
public ?array $actions = null;
15+
16+
17+
public function __construct(
18+
string|array|null $methods = null,
19+
string|array|null $actions = null,
20+
public ?bool $forward = null,
21+
public ?bool $sameOrigin = null,
22+
public ?bool $ajax = null,
23+
) {
24+
$this->methods = $methods === null ? null : (array) $methods;
25+
$this->actions = $actions === null ? null : (array) $actions;
26+
}
27+
}

src/Application/UI/Presenter.php

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Nette\Application\Responses;
1717
use Nette\Http;
1818
use Nette\Utils\Arrays;
19+
use Nette\Utils\Reflection;
1920

2021

2122
/**
@@ -288,14 +289,63 @@ protected function shutdown(Application\Response $response): void
288289
*/
289290
public function checkRequirements(\ReflectionClass|\ReflectionMethod $element): void
290291
{
292+
$attrs = array_map(
293+
fn($ra) => $ra->newInstance(),
294+
$element->getAttributes(Attributes\Requires::class, \ReflectionAttribute::IS_INSTANCEOF),
295+
);
296+
291297
if (
292298
$element instanceof \ReflectionMethod
293299
&& str_starts_with($element->getName(), 'handle')
294300
&& !ComponentReflection::parseAnnotation($element, 'crossOrigin')
295-
&& !$element->getAttributes(Nette\Application\Attributes\CrossOrigin::class)
296-
&& !$this->httpRequest->isSameSite()
301+
&& !$element->getAttributes(Attributes\CrossOrigin::class)
302+
&& !Arrays::some($attrs, fn($attr) => $attr->sameOrigin === false)
297303
) {
298-
$this->detectedCsrf();
304+
$attrs[] = new Attributes\Requires(sameOrigin: true);
305+
}
306+
307+
foreach ($attrs as $attribute) {
308+
if ($attribute->methods !== null) {
309+
if ($element instanceof \ReflectionClass) { // presenter class
310+
$this->allowedMethods = [];
311+
}
312+
$attribute->methods = array_map(strtoupper(...), $attribute->methods);
313+
if (!in_array($method = $this->httpRequest->getMethod(), $attribute->methods, strict: true)) {
314+
$this->httpResponse->setHeader('Allow', implode(',', $attribute->methods));
315+
$this->error("Method $method is not allowed by " . Reflection::toString($element), $this->httpResponse::S405_MethodNotAllowed);
316+
}
317+
}
318+
319+
if ($attribute->actions === ['*']) {
320+
if (!$element instanceof \ReflectionClass) { // presenter class
321+
throw new Nette\InvalidStateException("Option 'actions=*' used by " . Reflection::toString($element) . ' is allowed only on classes.');
322+
}
323+
if (!$this->getReflection()->hasCallableMethod(static::formatActionMethod($this->action))) {
324+
$this->error("Action '$this->action' is not allowed by " . Reflection::toString($element), $this->httpResponse::S403_Forbidden);
325+
}
326+
} elseif ($attribute->actions !== null) {
327+
if (
328+
$element instanceof \ReflectionMethod
329+
&& !$element->getDeclaringClass()->isSubclassOf(self::class)
330+
) {
331+
throw new Nette\InvalidStateException("Option 'actions' used by " . Reflection::toString($element) . ' is allowed only in presenter.');
332+
}
333+
if (!in_array($this->action, $attribute->actions, strict: true)) {
334+
$this->error("Action '$this->action' is not allowed by " . Reflection::toString($element), $this->httpResponse::S403_Forbidden);
335+
}
336+
}
337+
338+
if ($attribute->forward && !$this->request->isMethod($this->request::FORWARD)) {
339+
$this->error('Forwarded request is required by ' . Reflection::toString($element), $this->httpResponse::S403_Forbidden);
340+
}
341+
342+
if ($attribute->sameOrigin && !$this->httpRequest->isSameSite()) {
343+
$this->getPresenter()->detectedCsrf();
344+
}
345+
346+
if ($attribute->ajax && !$this->httpRequest->isAjax()) {
347+
$this->error('AJAX request is required by ' . Reflection::toString($element), $this->httpResponse::S403_Forbidden);
348+
}
299349
}
300350
}
301351

tests/UI/Requires.actions.phpt

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
/**
4+
* Test: #[Requires] option actions
5+
*/
6+
7+
declare(strict_types=1);
8+
9+
use Nette\Application;
10+
use Nette\Application\Attributes\Requires;
11+
use Nette\Http;
12+
use Tester\Assert;
13+
14+
require __DIR__ . '/../bootstrap.php';
15+
require __DIR__ . '/functions.php';
16+
17+
18+
#[Requires(actions: ['second'])]
19+
class TestActionsPresenter extends Nette\Application\UI\Presenter
20+
{
21+
public function actionSecond(): never
22+
{
23+
$this->terminate();
24+
}
25+
}
26+
27+
28+
$presenter = createPresenter(TestActionsPresenter::class);
29+
30+
Assert::noError(
31+
fn() => $presenter->run(new Application\Request('', Http\Request::Get, ['action' => 'second'])),
32+
);
33+
34+
Assert::exception(
35+
fn() => $presenter->run(new Application\Request('', Http\Request::Get)),
36+
Application\BadRequestException::class,
37+
"Action 'default' is not allowed by TestActionsPresenter",
38+
);

tests/UI/Requires.ajax.phpt

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
/**
4+
* Test: #[Requires] option actions
5+
*/
6+
7+
declare(strict_types=1);
8+
9+
use Nette\Application;
10+
use Nette\Application\Attributes\Requires;
11+
use Nette\Http;
12+
use Tester\Assert;
13+
14+
require __DIR__ . '/../bootstrap.php';
15+
require __DIR__ . '/functions.php';
16+
17+
18+
#[Requires(ajax: true)]
19+
class TestAjaxPresenter extends Nette\Application\UI\Presenter
20+
{
21+
public function actionDefault(): never
22+
{
23+
$this->terminate();
24+
}
25+
}
26+
27+
28+
$presenter = createPresenter(TestAjaxPresenter::class, headers: ['X-Requested-With' => 'XMLHttpRequest']);
29+
Assert::noError(
30+
fn() => $presenter->run(new Application\Request('Test', Http\Request::Get)),
31+
);
32+
33+
34+
$presenter = createPresenter(TestAjaxPresenter::class);
35+
Assert::exception(
36+
fn() => $presenter->run(new Application\Request('Test', Http\Request::Get)),
37+
Application\BadRequestException::class,
38+
'AJAX request is required by TestAjaxPresenter',
39+
);

tests/UI/Requires.forward.phpt

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
/**
4+
* Test: #[Requires] option actions
5+
*/
6+
7+
declare(strict_types=1);
8+
9+
use Nette\Application;
10+
use Nette\Application\Attributes\Requires;
11+
use Nette\Http;
12+
use Tester\Assert;
13+
14+
require __DIR__ . '/../bootstrap.php';
15+
require __DIR__ . '/functions.php';
16+
17+
18+
#[Requires(forward: true)]
19+
class TestForwardPresenter extends Nette\Application\UI\Presenter
20+
{
21+
public function actionDefault(): never
22+
{
23+
$this->terminate();
24+
}
25+
}
26+
27+
28+
$presenter = createPresenter(TestForwardPresenter::class);
29+
Assert::noError(
30+
fn() => $presenter->run(new Application\Request('', Application\Request::FORWARD)),
31+
);
32+
33+
Assert::exception(
34+
fn() => $presenter->run(new Application\Request('', Http\Request::Get)),
35+
Application\BadRequestException::class,
36+
'Forwarded request is required by TestForwardPresenter',
37+
);

tests/UI/Requires.locations.phpt

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<?php
2+
3+
/**
4+
* Test: Location of #[Requires]
5+
*/
6+
7+
declare(strict_types=1);
8+
9+
use Nette\Application;
10+
use Nette\Application\Attributes\Requires;
11+
use Nette\Http;
12+
use Tester\Assert;
13+
14+
require __DIR__ . '/../bootstrap.php';
15+
require __DIR__ . '/functions.php';
16+
17+
18+
#[Requires(forward: true)]
19+
class TestClassPresenter extends Nette\Application\UI\Presenter
20+
{
21+
public function actionDefault(): never
22+
{
23+
$this->terminate();
24+
}
25+
}
26+
27+
class TestMethodActionPresenter extends Nette\Application\UI\Presenter
28+
{
29+
#[Requires(forward: true)]
30+
public function actionDefault(): never
31+
{
32+
$this->terminate();
33+
}
34+
}
35+
36+
class TestMethodRenderPresenter extends Nette\Application\UI\Presenter
37+
{
38+
#[Requires(forward: true)]
39+
public function renderDefault(): never
40+
{
41+
$this->terminate();
42+
}
43+
}
44+
45+
class TestMethodHandlePresenter extends Nette\Application\UI\Presenter
46+
{
47+
#[Requires(forward: true)]
48+
public function handleFoo(): never
49+
{
50+
$this->terminate();
51+
}
52+
}
53+
54+
55+
// class-level attribute
56+
$presenter = createPresenter(TestClassPresenter::class);
57+
Assert::noError(
58+
fn() => $presenter->run(new Application\Request('', Application\Request::FORWARD)),
59+
);
60+
61+
Assert::exception(
62+
fn() => $presenter->run(new Application\Request('', Http\Request::Get)),
63+
Application\BadRequestException::class,
64+
'Forwarded request is required by TestClassPresenter',
65+
);
66+
67+
68+
// method action<name>()
69+
$presenter = createPresenter(TestMethodActionPresenter::class);
70+
Assert::noError(
71+
fn() => $presenter->run(new Application\Request('', Application\Request::FORWARD)),
72+
);
73+
74+
Assert::exception(
75+
fn() => $presenter->run(new Application\Request('', Http\Request::Get)),
76+
Application\BadRequestException::class,
77+
'Forwarded request is required by TestMethodActionPresenter::actionDefault()',
78+
);
79+
80+
81+
// method render<name>()
82+
$presenter = createPresenter(TestMethodRenderPresenter::class);
83+
Assert::noError(
84+
fn() => $presenter->run(new Application\Request('', Application\Request::FORWARD)),
85+
);
86+
87+
Assert::exception(
88+
fn() => $presenter->run(new Application\Request('', Http\Request::Get)),
89+
Application\BadRequestException::class,
90+
'Forwarded request is required by TestMethodRenderPresenter::renderDefault()',
91+
);
92+
93+
94+
// method handle<name>()
95+
$presenter = createPresenter(TestMethodHandlePresenter::class);
96+
Assert::noError(
97+
fn() => $presenter->run(new Application\Request('', Application\Request::FORWARD, ['do' => 'foo'])),
98+
);
99+
100+
Assert::exception(
101+
fn() => $presenter->run(new Application\Request('', Http\Request::Get, ['do' => 'foo'])),
102+
Application\BadRequestException::class,
103+
'Forwarded request is required by TestMethodHandlePresenter::handleFoo()',
104+
);

tests/UI/Requires.methods.phpt

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
/**
4+
* Test: #[Requires] option actions
5+
*/
6+
7+
declare(strict_types=1);
8+
9+
use Nette\Application;
10+
use Nette\Application\Attributes\Requires;
11+
use Nette\Http;
12+
use Tester\Assert;
13+
14+
require __DIR__ . '/../bootstrap.php';
15+
require __DIR__ . '/functions.php';
16+
17+
18+
#[Requires(methods: ['OPTIONS'])]
19+
class TestMethodsPresenter extends Nette\Application\UI\Presenter
20+
{
21+
public function actionDefault(): never
22+
{
23+
$this->terminate();
24+
}
25+
}
26+
27+
28+
$presenter = createPresenter(TestMethodsPresenter::class, method: 'OPTIONS');
29+
Assert::noError(
30+
fn() => $presenter->run(new Application\Request('', Http\Request::Get)),
31+
);
32+
33+
34+
$presenter = createPresenter(TestMethodsPresenter::class);
35+
Assert::exception(
36+
fn() => $presenter->run(new Application\Request('', Http\Request::Get)),
37+
Application\BadRequestException::class,
38+
'Method GET is not allowed by TestMethodsPresenter',
39+
);

0 commit comments

Comments
 (0)