Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
.gitattributes export-ignore
.github/ export-ignore
.gitignore export-ignore
AGENTS.md export-ignore
ncs.* export-ignore
phpstan*.neon export-ignore
src/**/*.latte export-ignore
docs/ export-ignore
tests/ export-ignore

*.php* diff=php
Expand Down
61 changes: 61 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# To My Agents!

It is my fervent wish that this file guide every AI coding agent working with code in this repository.

## Documentation

Any distilled, agent-facing documentation for this package - how it works
internally and the rationale behind key design decisions - lives in `docs/`.
Consult it before non-trivial changes; it is the source of truth from which the
public manual is distilled.

The presenter lifecycle, link generation, persistent state, routing, and template
lookup carry subtle non-local invariants - read `docs/internals` before touching
them.

## Project Overview

Nette Application is a full-stack, component-based MVC framework library for PHP -
the core application layer of the Nette Framework: presenters, components, routing,
templating integration, and the request/response cycle. It is a library package,
not a runnable application, and integrates with the rest of the framework through
`src/Bridges` (DI, Latte, Tracy).

- **PHP Version**: 8.3 - 8.5
- **Package**: `nette/application`

## Essential Commands

```bash
# Run all tests
composer tester # or: vendor/bin/tester tests -s

# Run one test directory / file
vendor/bin/tester tests/UI -s
php tests/Application/Presenter.twoDomains.phpt

# Static analysis (PHPStan level 8)
composer phpstan
```

## Conventions

- Every file starts with `declare(strict_types=1);`; tabs for indentation; Nette
Coding Standard (PSR-12 based) with the opening brace on its own line.
- Tests are Nette Tester `.phpt` files mirroring `src/` under `tests/`; use
`test()` / `testException()` and `Assert::*`. Temp files go to `tests/tmp/{pid}/`,
and `tests/types/` holds PHPStan type-inference fixtures.

## Working in this repo

- **The presenter lifecycle is exact and load-bearing.** The precise phase order
(action, canonicalization, signal dispatch, `beforeRender`, `render`, the
`onRender` hook, `afterRender`), `AbortException`-based termination, and the
4xx-not-500 invalid-link contract are documented in `docs/internals/lifecycle.md`.
Don't reconstruct them from memory.
- **Links and persistent state span the whole component tree.** Link-generation
modes and whole-tree global-state aggregation are the trickiest code here; see
`docs/internals/link-generation.md` and `persistent-state.md`.
- User-facing how-to (routing DSL, signals, snippets, component factories, flash
messages, DI/NEON configuration) is manual material and lives in the public web
docs, not here.
10 changes: 5 additions & 5 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
],
"require": {
"php": "8.3 - 8.5",
"nette/component-model": "^3.2 || ^4.0",
"nette/component-model": "^4.0",
"nette/http": "^3.4",
"nette/routing": "^3.1.1",
"nette/utils": "^4.1"
Expand All @@ -29,7 +29,7 @@
"nette/tester": "^2.6",
"nette/caching": "^3.2",
"nette/di": "^3.2",
"nette/forms": "^3.2",
"nette/forms": "^4.0",
"nette/robot-loader": "^4.0",
"nette/security": "^3.2",
"latte/latte": "^3.1.4",
Expand All @@ -43,9 +43,9 @@
"conflict": {
"nette/caching": "<3.2",
"nette/di": "<3.2",
"nette/forms": "<3.2",
"nette/forms": "<4.0",
"nette/schema": "<1.3",
"latte/latte": "<3.1.4 || >=3.2",
"latte/latte": "<3.1.5 || >=3.2",
"tracy/tracy": "<2.12"
},
"autoload": {
Expand All @@ -61,7 +61,7 @@
},
"extra": {
"branch-alias": {
"dev-master": "3.3-dev"
"dev-master": "4.0-dev"
}
},
"config": {
Expand Down
81 changes: 81 additions & 0 deletions docs/decisions/2025-02-27 linkbase-tag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# {linkBase} tag - compile-time base for relative links in a template

Status: accepted · 2025-02-27 · branch `v3.2` (released in 3.2.7)

## Context

A flexible directory structure is one of the most valuable features of Nette
Application, but it creates a problem with shared layouts: relative link targets
(`Dashboard:`, `Products:Overview:`) are resolved against the *current*
presenter. When the same layout is used by both `Admin:Dashboard` and
`Admin:Products:Detail`, the target `Dashboard:` resolves correctly to
`Admin:Dashboard:default` in one case and to the non-existent
`Admin:Products:Dashboard:default` in the other.

The previous workaround was to write every target in layouts absolutely
(`:Admin:Dashboard:`). It works, but the module prefix is duplicated in every
link, so moving or renaming a module means editing templates en masse.

## Considered options

- **Status quo (absolute links everywhere)** - no new concept, but prefix
duplication and fragility when refactoring modules.
- **Runtime solution** (setting the base on the presenter/component, or a
template variable threaded into `LinkGenerator`) - collides with the shared
template cache: the same compiled layout is used by presenters from different
modules, so the base would have to become part of the cache key, or a runtime
cost would be paid on every link. It would also make the template no longer
self-describing - a link's target would depend on state set somewhere in PHP.
- **Compile-time tag in the template** (chosen) - the base is declared directly
in the template it concerns, and expansion happens at compile time.

## Decision

A new tag `{linkBase module}` plus a compile-time pass `applyLinkBasePass`
registered in `UIExtension::getPasses()`:

- The tag must appear in the template head (`Tag::isInHead()`, otherwise
`CompileException`) and applies to the **whole template file** - not to
included files, which are compiled separately and may carry their own
`{linkBase}`. It emits no code itself (`print()` returns `''`); all the work
is done by the pass. Only the first tag found is used
(`NodeHelpers::findFirst`).
- The pass traverses the AST and rewrites **all** `LinkNode`s - i.e. `{link}`,
`{plink}` and `n:href` uniformly.
- When both the target and the base are static strings, the destination is
rewritten directly at compile time (`LinkGenerator::applyBase()` called from
the pass) - **zero runtime overhead**. When either is an expression, the
destination is wrapped in a runtime call `LinkGenerator::applyBase(%node,
%node)` via `AuxiliaryNode`.
- Semantics of `LinkGenerator::applyBase($link, $base)`: the prefix `:$base:` is
added only when the target **contains a colon and does not start with one**.
Thus the base applies exclusively to relative targets that name a presenter
(`Users:list` → `:Admin:Users:list`); untouched are absolute targets
(`:Foo:`), targets within the current presenter (`show`, `this`), signals
(`click!`) and aliases - none of which have a colon in the middle.

## Consequences

- (+) Layouts and templates shared by presenters at different module depths link
consistently, regardless of who renders them.
- (+) Renaming or moving a module is a single change in `{linkBase}` instead of
editing every link.
- (+) For static links, expansion happens at compile time; nothing is paid at
runtime.
- (−) Another tag and concept to learn; the per-template-file scope may surprise
(includes do not inherit the base).
- (−) The base cannot be set per presenter/module from PHP - that remains open.

## Code

- `src/Bridges/ApplicationLatte/Nodes/LinkBaseNode.php` - the tag and the
`applyLinkBasePass` pass
- `src/Application/LinkGenerator.php` - `applyBase()` (`@internal`)
- tag and pass registration in `src/Bridges/ApplicationLatte/UIExtension.php`
(`getTags()`, `getPasses()`)
- test: `tests/Bridges.Latte/{linkBase}.phpt` (static and dynamic targets)
- commit `e3beab847`, released in `v3.2.7`

The `|absoluteUrl` filter shipped in the same release for forcing an absolute URL
on an arbitrary value (image path, database value), complementing this tag for
cases outside presenter link targets.
58 changes: 58 additions & 0 deletions docs/internals/access-control.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Access control & CSRF

`AccessPolicy` (internal) evaluates `#[Requires]` attributes. The attribute is
repeatable, class- or method-level, and matched with `IS_INSTANCEOF`, so subclasses
(like the deprecated `#[CrossOrigin]`) count as `Requires` instances.

## Enforcement runs at three sites

No single file shows this; the policy is applied wherever user code is about to run:

1. **Class-level** — `Presenter::run()`, in the checkRequirements phase, on the
presenter's own reflection (before `startup`).
2. **Method-level** — `Component::tryCall()`, i.e. on every `action*`, `render*`
and `handle*` invocation, right before argument conversion.
3. **Factory methods** — `Component::createComponent()` checks the policy on the
`createComponent<Name>()` method, so a component can be guarded at creation.

At the first two sites the (older, unrelated) `checkRequirements()` user hook is
also called with the same reflection — the two mechanisms coexist. The factory
site runs only the attribute policy.

## `#[Requires]` semantics

- `methods:` — allowed HTTP methods; `['*']` is a wildcard. **Trap:** a
*class-level* `methods:` empties `$presenter->allowedMethods` to bypass the
deprecated legacy `Presenter::checkHttpMethod()`, so the attribute fully replaces
it. Violation → 405 with an `Allow` header.
- `actions:` — method-level use is allowed **only in presenters**
(`LogicException` otherwise). Violation → 4xx via `error()`.
- `forward: true` — the element is reachable only via forward; it also makes the
element **non-linkable**: `AccessPolicy::isLinkable()` is what
`LinkGenerator::validateLinkTarget` consults for every mode except `forward`
(see link-generation.md). Violation at runtime → 4xx.
- `ajax: true` — non-AJAX request → 403.
- `sameOrigin: true` — see below.

## The implicit signal CSRF rule

**Every `handle*` method implicitly gets `Requires(sameOrigin: true)`** — added by
`AccessPolicy::applyInternalRules` — unless the method opts out via
`#[Requires(sameOrigin: false)]`, the deprecated `#[CrossOrigin]`, or a legacy
`@crossOrigin` annotation. Same-origin is verified from the **`Sec-Fetch-Site`
header** (`$httpRequest->isFrom(FetchSite::SameOrigin)`), not from a SameSite
cookie.

A violation does **not** produce an error: it calls `Presenter::detectedCsrf()`,
whose default is `redirect('this')` — **the signal is silently dropped** and the
page reloads without it. `detectedCsrf()` is public and overridable.

## `UI\Form` has its own, parallel check

`Form::signalReceived('submit')` does **not** go through `AccessPolicy` for CSRF:
it re-checks `Sec-Fetch-Site` itself, gated by the `$crossOrigin` flag from
nette/forms (`allowCrossOrigin()`), and calls the same `detectedCsrf()`. Two more
suppressions live here: a request with the `RESTORED` flag (set by
`Presenter::restoreRequest` for re-sent POSTs) skips `fireEvents()` entirely, and
`receiveHttpData()` returns null for forwarded requests — a form never submits
across a forward.
75 changes: 75 additions & 0 deletions docs/internals/lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Presenter lifecycle

`Presenter::run(Request): Response` is a fixed sequence; the exact ordering is the
non-local knowledge (some steps sit where a reader would not expect).

## Phase order (`run`)

Before the `try`: `initGlobalParameters()` parses the action, the signal, and the
signal receiver, and calls `loadState()`. Then, inside the `try`:

1. **checkRequirements** — access policy, `checkRequirements`, HTTP method check.
2. **startup** — `onStartup`, then `startup()`, then a guard that the child called
`parent::startup()` (else `InvalidStateException`).
3. **`action<Action>()`** — via `tryCall`. A `SwitchException` here re-enters with a
changed action **and disables `autoCanonicalize`**.
4. **component autoload** — every id in `globalParams` is `getComponent(…, throw:
false)`d.
5. **canonicalize** — only if `autoCanonicalize` (see below).
6. **HEAD** — terminates early on a HEAD request.
7. **`handle<Signal>()`** — `processSignal()`. **This runs after the action and
before rendering**, which is the ordering that surprises people.
8. **beforeRender** → **`onRender`** (a hook that is easy to forget) → **`render<View>()`**
(again `SwitchException` → `setView` + retry) → **afterRender**.
9. **sendTemplate** — completes and sends the template response.

So the true order is **startup → action → (autoload + canonicalize) → signal →
beforeRender → onRender → render → afterRender → sendTemplate**.

A `SwitchException` escaping anywhere other than the two retry sites above is
turned by `run()` into a `LogicException` ("Switch is only allowed inside
action*() or render*() method").

## Termination is by exception; there is no explicit "frozen" flag

Every terminating method funnels through `sendResponse()` → `terminate()`, which
**throws `AbortException`**: `sendTemplate`, `sendPayload`/`sendJson`,
`redirectUrl`, `forward`. `run()`'s **empty** `catch (AbortException)` then lets
control fall through to the post-lifecycle steps. So there is no separate "state is
frozen" mechanism — the response method simply throws, and nothing after it in the
lifecycle runs. (`error()` is different: it throws `BadRequestException`, which is
**not** caught in `run()` and bubbles up to `Application`.)

**Post-lifecycle, always runs** (even after redirect/JSON, because the abort lands
here): `saveGlobalState()`, then the AJAX payload block (`payload->state =
getGlobalState()`, snippet mode for an invalidated `TextResponse`), flash-session
30-second expiration, a `VoidResponse` fallback, `onShutdown`, `shutdown()`, and
`return $this->response`.

## The template file is resolved lazily, at `sendTemplate`

`completeTemplate()` copies the presenter's `#[TemplateVariable]` properties into
the template (see templates.md) and, **only if the template has no file set**,
calls `findTemplateFile()` **then** — so template lookup happens at send time, not
when the template object is created. Changing the view is possible right
up to `render<View>`; after `sendTemplate` throws `AbortException` nothing more can
affect it.

## Canonicalization uses the `redirectX` mode

`canonicalize()` (auto-called at step 5 unless `autoCanonicalize` was turned off by
an action switch) returns immediately for AJAX or non-GET/HEAD. Otherwise it builds
the canonical URL with `link(..., 'redirectX')` (see link-generation.md — an
absolute URL with no flash key, used purely for comparison), swallows any
`InvalidLinkException`, and if the current URL differs, redirects — **302 when the
`VARYING` request flag is set, else 301**. That flag is set by the router (external
`nette/routing`); this package only reads it.

## Signals

`initGlobalParameters` reads the signal from `_do` (POST) / `do` (the `SignalKey`),
splitting `{receiver}-{signal}` at the **last** `-` (so a receiver id may itself
contain dashes). `processSignal()` resolves the receiver (`''` = the presenter
itself), raising `BadSignalException` if it is missing or not a `SignalReceiver`,
then calls `signalReceived()` → `handle<Signal>()`. A signal always targets the
current presenter/action; the URL carries it as `?do=<receiver>-<signal>`.
Loading
Loading