├── .phpstorm.meta.php ├── composer.json ├── license.md ├── readme.md └── src ├── Bridges ├── HttpDI │ ├── HttpExtension.php │ └── SessionExtension.php └── HttpTracy │ ├── SessionPanel.php │ ├── dist │ ├── panel.phtml │ └── tab.phtml │ ├── panel.latte │ └── tab.latte └── Http ├── Context.php ├── FileUpload.php ├── Helpers.php ├── IRequest.php ├── IResponse.php ├── Request.php ├── RequestFactory.php ├── Response.php ├── Session.php ├── SessionSection.php ├── Url.php ├── UrlImmutable.php └── UrlScript.php /.phpstorm.meta.php: -------------------------------------------------------------------------------- 1 | getUrl(); 59 | echo $url; // https://nette.org/en/documentation?action=edit 60 | echo $url->getHost(); // nette.org 61 | ``` 62 | 63 | Browsers do not send a fragment to the server, so `$url->getFragment()` will return an empty string. 64 | 65 | getQuery(string $key = null): string|array|null 66 | ----------------------------------------------- 67 | Returns GET request parameters: 68 | 69 | ```php 70 | $all = $httpRequest->getQuery(); // array of all URL parameters 71 | $id = $httpRequest->getQuery('id'); // returns GET parameter 'id' (or null) 72 | ``` 73 | 74 | getPost(string $key = null): string|array|null 75 | ---------------------------------------------- 76 | Returns POST request parameters: 77 | 78 | ```php 79 | $all = $httpRequest->getPost(); // array of all POST parameters 80 | $id = $httpRequest->getPost('id'); // returns POST parameter 'id' (or null) 81 | ``` 82 | 83 | getFile(string $key): Nette\Http\FileUpload|array|null 84 | ------------------------------------------------------ 85 | Returns [upload](#Uploaded-Files) as object [Nette\Http\FileUpload](https://api.nette.org/3.0/Nette/Http/FileUpload.html): 86 | 87 | ```php 88 | $file = $httpRequest->getFile('avatar'); 89 | if ($file->hasFile()) { // was any file uploaded? 90 | $file->getName(); // name of the file sent by user 91 | $file->getSanitizedName(); // the name without dangerous characters 92 | } 93 | ``` 94 | 95 | getFiles(): array 96 | ----------------- 97 | Returns tree of [upload files](#Uploaded-Files) in a normalized structure, with each leaf an instance of [Nette\Http\FileUpload](https://api.nette.org/3.0/Nette/Http/FileUpload.html): 98 | 99 | ```php 100 | $files = $httpRequest->getFiles(); 101 | ``` 102 | 103 | getCookie(string $key): string|array|null 104 | ----------------------------------------- 105 | Returns a cookie or `null` if it does not exist. 106 | 107 | ```php 108 | $sessId = $httpRequest->getCookie('sess_id'); 109 | ``` 110 | 111 | getCookies(): array 112 | ------------------- 113 | Returns all cookies: 114 | 115 | ```php 116 | $cookies = $httpRequest->getCookies(); 117 | ``` 118 | 119 | getMethod(): string 120 | ------------------- 121 | Returns the HTTP method with which the request was made. 122 | 123 | ```php 124 | echo $httpRequest->getMethod(); // GET, POST, HEAD, PUT 125 | ``` 126 | 127 | isMethod(string $method): bool 128 | ------------------------------ 129 | Checks the HTTP method with which the request was made. The parameter is case-insensitive. 130 | 131 | ```php 132 | if ($httpRequest->isMethod('GET')) ... 133 | ``` 134 | 135 | getHeader(string $header): ?string 136 | ---------------------------------- 137 | Returns an HTTP header or `null` if it does not exist. The parameter is case-insensitive: 138 | 139 | ```php 140 | $userAgent = $httpRequest->getHeader('User-Agent'); 141 | ``` 142 | 143 | getHeaders(): array 144 | ------------------- 145 | Returns all HTTP headers as associative array: 146 | 147 | ```php 148 | $headers = $httpRequest->getHeaders(); 149 | echo $headers['Content-Type']; 150 | ``` 151 | 152 | getReferer(): ?Nette\Http\UrlImmutable 153 | -------------------------------------- 154 | What URL did the user come from? Beware, it is not reliable at all. 155 | 156 | isSecured(): bool 157 | ----------------- 158 | Is the connection encrypted (HTTPS)? You may need to [set up a proxy|configuring#HTTP proxy] for proper functionality. 159 | 160 | isSameSite(): bool 161 | ------------------ 162 | Is the request coming from the same (sub) domain and is initiated by clicking on a link? 163 | 164 | isAjax(): bool 165 | -------------- 166 | Is it an AJAX request? 167 | 168 | getRemoteAddress(): ?string 169 | --------------------------- 170 | Returns the user's IP address. You may need to [set up a proxy|configuring#HTTP proxy] for proper functionality. 171 | 172 | getRemoteHost(): ?string 173 | ------------------------ 174 | Returns DNS translation of the user's IP address. You may need to [set up a proxy|configuring#HTTP proxy] for proper functionality. 175 | 176 | getRawBody(): ?string 177 | --------------------- 178 | Returns the body of the HTTP request: 179 | 180 | ```php 181 | $body = $httpRequest->getRawBody(); 182 | ``` 183 | 184 | detectLanguage(array $langs): ?string 185 | ------------------------------------- 186 | Detects language. As a parameter `$lang`, we pass an array of languages ​​that the application supports, and it returns the one preferred by browser. It is not magic, the method just uses the `Accept-Language` header. If no match is reached, it returns `null`. 187 | 188 | ```php 189 | // Header sent by browser: Accept-Language: cs,en-us;q=0.8,en;q=0.5,sl;q=0.3 190 | 191 | $langs = ['hu', 'pl', 'en']; // languages supported in application 192 | echo $httpRequest->detectLanguage($langs); // en 193 | ``` 194 | 195 | 196 | 197 | RequestFactory 198 | -------------- 199 | 200 | The object of the current HTTP request is created by [Nette\Http\RequestFactory](https://api.nette.org/3.0/Nette/Http/RequestFactory.html). If you are writing an application that does not use a DI container, you create a request as follows: 201 | 202 | ```php 203 | $factory = new Nette\Http\RequestFactory; 204 | $httpRequest = $factory->fromGlobals(); 205 | ``` 206 | 207 | RequestFactory can be configured before calling `fromGlobals()`. We can disable all sanitization of input parameters from invalid UTF-8 sequences using `$factory->setBinary()`. And also set up a proxy server, which is important for the correct detection of the user's IP address using `$factory->setProxy(...)`. 208 | 209 | It's possible to clean up URLs from characters that can get into them because of poorly implemented comment systems on various other websites by using filters: 210 | 211 | ```php 212 | // remove spaces from path 213 | $requestFactory->urlFilters['path']['%20'] = ''; 214 | 215 | // remove dot, comma or right parenthesis form the end of the URL 216 | $requestFactory->urlFilters['url']['[.,)]$'] = ''; 217 | 218 | // clean the path from duplicated slashes (default filter) 219 | $requestFactory->urlFilters['path']['/{2,}'] = '/'; 220 | ``` 221 | 222 | 223 | 224 | HTTP Response 225 | ============= 226 | 227 | An HTTP response is an [Nette\Http\Response](https://api.nette.org/3.0/Nette/Http/Response.html) object. Unlike the [Request](#HTTP-Request), the object is mutable, so you can use setters to change the state, ie to send headers. Remember that all setters **must be called before any actual output is sent.** The `isSent()` method tells if output have been sent. If it returns `true`, each attempt to send a header throws an `Nette\InvalidStateException` exception. 228 | 229 | 230 | setCode(int $code, string $reason = null) 231 | ----------------------------------------- 232 | Changes a status [response code](https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10). For better source code readability it is recommended to use [predefined constants](https://api.nette.org/3.0/Nette/Http/IResponse.html) instead of actual numbers. 233 | 234 | ```php 235 | $httpResponse->setCode(Nette\Http\Response::S404_NotFound); 236 | ``` 237 | 238 | getCode(): int 239 | -------------- 240 | Returns the status code of the response. 241 | 242 | isSent(): bool 243 | -------------- 244 | Returns whether headers have already been sent from the server to the browser, so it is no longer possible to send headers or change the status code. 245 | 246 | setHeader(string $name, string $value) 247 | -------------------------------------- 248 | Sends an HTTP header and **overwrites** previously sent header of the same name. 249 | 250 | ```php 251 | $httpResponse->setHeader('Pragma', 'no-cache'); 252 | ``` 253 | 254 | addHeader(string $name, string $value) 255 | -------------------------------------- 256 | Sends an HTTP header and **doesn't overwrite** previously sent header of the same name. 257 | 258 | ```php 259 | $httpResponse->addHeader('Accept', 'application/json'); 260 | $httpResponse->addHeader('Accept', 'application/xml'); 261 | ``` 262 | 263 | deleteHeader(string $name) 264 | -------------------------- 265 | Deletes a previously sent HTTP header. 266 | 267 | getHeader(string $header): ?string 268 | ---------------------------------- 269 | Returns the sent HTTP header, or `null` if it does not exist. The parameter is case-insensitive. 270 | 271 | ```php 272 | $pragma = $httpResponse->getHeader('Pragma'); 273 | ``` 274 | 275 | getHeaders(): array 276 | ------------------- 277 | Returns all sent HTTP headers as associative array. 278 | 279 | ```php 280 | $headers = $httpResponse->getHeaders(); 281 | echo $headers['Pragma']; 282 | ``` 283 | 284 | setContentType(string $type, string $charset = null) 285 | ---------------------------------------------------- 286 | Sends the header `Content-Type`. 287 | 288 | ```php 289 | $httpResponse->setContentType('text/plain', 'UTF-8'); 290 | ``` 291 | 292 | redirect(string $url, int $code = self::S302_FOUND): void 293 | --------------------------------------------------------- 294 | Redirects to another URL. Don't forget to quit the script then. 295 | 296 | ```php 297 | $httpResponse->redirect('http://example.com'); 298 | exit; 299 | ``` 300 | 301 | setExpiration(?string $time) 302 | ---------------------------- 303 | Sets the expiration of the HTTP document using the `Cache-Control` and `Expires` headers. The parameter is either a time interval (as text) or `null`, which disables caching. 304 | 305 | ```php 306 | // browser cache expires in one hour 307 | $httpResponse->setExpiration('1 hour'); 308 | ``` 309 | 310 | setCookie(string $name, string $value, string|int|\DateTimeInterface|null $expire, string $path = null, string $domain = null, bool $secure = null, bool $httpOnly = null, string $sameSite = null) 311 | -------------------------------------------------------------------------------------------------------------------------------------------------------------- 312 | Sends a cookie. The default values ​​of the parameters are: 313 | - `$path` with scope to all directories (`'/'`) 314 | - `$domain` with scope of the current (sub)domain, but not its subdomains 315 | - `$secure` defaults to false 316 | - `$httpOnly` is true, so the cookie is inaccessible to JavaScript 317 | - `$sameSite` is null, so the flag is not specified 318 | 319 | The `$expire` parameter can be specified as a string, an object implementing `DateTimeInterface`, or the number of seconds. 320 | 321 | ```php 322 | $httpResponse->setCookie('lang', 'en', '100 days'); 323 | ``` 324 | 325 | deleteCookie(string $name, string $path = null, string $domain = null, bool $secure = null): void 326 | ------------------------------------------------------------------------------------------------- 327 | Deletes a cookie. The default values ​​of the parameters are: 328 | - `$path` with scope to all directories (`'/'`) 329 | - `$domain` with scope of the current (sub)domain, but not its subdomains 330 | - `$secure` defaults to false 331 | 332 | ```php 333 | $httpResponse->deleteCookie('lang'); 334 | ``` 335 | 336 | 337 | Uploaded Files 338 | ============== 339 | 340 | Method `Nette\Http\Request::getFiles()` return a tree of upload files in a normalized structure, with each leaf an instance of `Nette\Http\FileUpload`. These objects encapsulate the data submitted by the `` form element. 341 | 342 | The structure reflects the naming of elements in HTML. In the simplest example, this might be a single named form element submitted as: 343 | 344 | ```html 345 | 346 | ``` 347 | 348 | In this case, the `$request->getFiles()` returns array: 349 | 350 | ```php 351 | [ 352 | 'avatar' => /* FileUpload instance */ 353 | ] 354 | ``` 355 | 356 | The `FileUpload` object is created even if the user did not upload any file or the upload failed. Method `hasFile()` returns true if a file has been sent: 357 | 358 | ```php 359 | $request->getFile('avatar')->hasFile(); 360 | ``` 361 | 362 | In the case of an input using array notation for the name: 363 | 364 | ```html 365 | 366 | ``` 367 | 368 | returned tree ends up looking like this: 369 | 370 | ```php 371 | [ 372 | 'my-form' => [ 373 | 'details' => [ 374 | 'avatar' => /* FileUpload instance */ 375 | ], 376 | ], 377 | ] 378 | ``` 379 | 380 | You can also create arrays of files: 381 | 382 | ```html 383 | 384 | ``` 385 | 386 | In such a case structure looks like: 387 | 388 | ```php 389 | [ 390 | 'my-form' => [ 391 | 'details' => [ 392 | 'avatars' => [ 393 | 0 => /* FileUpload instance */, 394 | 1 => /* FileUpload instance */, 395 | 2 => /* FileUpload instance */, 396 | ], 397 | ], 398 | ], 399 | ] 400 | ``` 401 | 402 | The best way to access index 1 of a nested array is as follows: 403 | 404 | ```php 405 | $file = Nette\Utils\Arrays::get( 406 | $request->getFiles(), 407 | ['my-form', 'details', 'avatars', 1], 408 | null 409 | ); 410 | if ($file instanceof FileUpload) { 411 | ... 412 | } 413 | ``` 414 | 415 | Because you can't trust data from the outside and therefore don't rely on the form of the file structure, it's safer to use the `Arrays::get()` than the `$request->getFiles()['my-form']['details']['avatars'][1]`, which may fail. 416 | 417 | 418 | Overview of `FileUpload` Methods .{toc: FileUpload} 419 | --------------------------------------------------- 420 | 421 | hasFile(): bool 422 | --------------- 423 | Returns `true` if the user has uploaded a file. 424 | 425 | isOk(): bool 426 | ------------ 427 | Returns `true` if the file was uploaded successfully. 428 | 429 | getError(): int 430 | --------------- 431 | Returns the error code associated with the uploaded file. It is be one of [UPLOAD_ERR_XXX](http://php.net/manual/en/features.file-upload.errors.php) constants. If the file was uploaded successfully, it returns `UPLOAD_ERR_OK`. 432 | 433 | move(string $dest) 434 | ------------------ 435 | Moves an uploaded file to a new location. If the destination file already exists, it will be overwritten. 436 | 437 | ```php 438 | $file->move('/path/to/files/name.ext'); 439 | ``` 440 | 441 | getContents(): ?string 442 | ---------------------- 443 | Returns the contents of the uploaded file. If the upload was not successful, it returns `null`. 444 | 445 | getContentType(): ?string 446 | ------------------------- 447 | Detects the MIME content type of the uploaded file based on its signature. If the upload was not successful or the detection failed, it returns `null`. 448 | 449 | Requires PHP extension `fileinfo`. 450 | 451 | getName(): string 452 | ----------------- 453 | Returns the original file name as submitted by the browser. 454 | 455 | Do not trust the value returned by this method. A client could send a malicious filename with the intention to corrupt or hack your application. 456 | 457 | getSanitizedName(): string 458 | -------------------------- 459 | Returns the sanitized file name. It contains only ASCII characters `[a-zA-Z0-9.-]`. If the name does not contain such characters, it returns 'unknown'. If the file is JPEG, PNG, GIF, or WebP image, it returns the correct file extension. 460 | 461 | getSize(): int 462 | -------------- 463 | Returns the size of the uploaded file. If the upload was not successful, it returns `0`. 464 | 465 | getTemporaryFile(): string 466 | -------------------------- 467 | Returns the path of the temporary location of the uploaded file. If the upload was not successful, it returns `''`. 468 | 469 | isImage(): bool 470 | --------------- 471 | Returns `true` if the uploaded file is a JPEG, PNG, GIF, or WebP image. Detection is based on its signature. The integrity of the entire file is not checked. You can find out if an image is not corrupted for example by trying to [load it](#toImage). 472 | 473 | Requires PHP extension `fileinfo`. 474 | 475 | getImageSize(): ?array 476 | ---------------------- 477 | Returns a pair of `[width, height]` with dimensions of the uploaded image. If the upload was not successful or is not a valid image, it returns `null`. 478 | 479 | toImage(): Nette\Utils\Image 480 | ---------------------------- 481 | Loads an image as an `Image` object. If the upload was not successful or is not a valid image, it throws an `Nette\Utils\ImageException` exception. 482 | 483 | 484 | 485 | Sessions 486 | ======== 487 | 488 | When using sessions, each user receives a unique identifier called session ID, which is passed in a cookie. This serves as the key to the session data. Unlike cookies, which are stored on the browser side, session data is stored on the server side. 489 | 490 | The session is managed by the [Nette\Http\Session](https://api.nette.org/3.0/Nette/Http/Session.html) object. 491 | 492 | 493 | Starting Session 494 | ---------------- 495 | 496 | By default, Nette automatically starts a session if the HTTP request contains a cookie with a session ID. It also starts automatically when we start reading from or writing data to it. Manually is session started by `$session->start()`. 497 | 498 | PHP sends HTTP headers affecting caching when starting the session, see `session_cache_limiter`, and possibly a cookie with the session ID. Therefore, it is always necessary to start the session before sending any output to the browser, otherwise an exception will be thrown. So if you know that a session will be used during page rendering, start it manually before, for example in the presenter. 499 | 500 | In developer mode, Tracy starts the session because it uses it to display redirection and AJAX requests bars in the Tracy Bar. 501 | 502 | 503 | Section 504 | ------- 505 | 506 | In pure PHP, the session data store is implemented as an array accessible via a global variable `$_SESSION`. The problem is that applications normally consist of a number of independent parts, and if all have only one same array available, sooner or later a name collision will occur. 507 | 508 | Nette Framework solves the problem by dividing the entire space into sections (objects [Nette\Http\SessionSection](https://api.nette.org/3.0/Nette/Http/SessionSection.html)). Each unit then uses its own section with a unique name and no collisions can occur. 509 | 510 | We get the section from the session manager: 511 | 512 | ```php 513 | $section = $session->getSession('unique name'); 514 | ``` 515 | 516 | In the presenter it is enough to call `getSession()` with the parameter: 517 | 518 | ```php 519 | // $this is Presenter 520 | $section = $this->getSession('unique name'); 521 | ``` 522 | 523 | The existence of the section can be checked by the method `$session->hasSection('unique name')`. 524 | 525 | And then it's really simple to work with that section: 526 | 527 | ```php 528 | // variable writing 529 | $section->userName = 'john'; // nebo $section['userName'] = 'john'; 530 | 531 | // variable reading 532 | echo $section->userName; // nebo echo $section['userName']; 533 | 534 | // variable removing 535 | unset($section->userName); // unset($section['userName']); 536 | ``` 537 | 538 | It's possible to use `foreach` cycle to obtain all variables from section: 539 | 540 | ```php 541 | foreach ($section as $key => $val) { 542 | echo "$key = $val"; 543 | } 544 | ``` 545 | 546 | Accessing a non-existent variable does not generate any error (the returned value is null). It could be undesirable behavior in some cases and that's why there is a possibility to change it: 547 | 548 | ```php 549 | $section->warnOnUndefined = true; 550 | ``` 551 | 552 | 553 | How to Set Expiration 554 | --------------------- 555 | 556 | Expiration can be set for individual sections or even individual variables. We can let the user's login expire in 20 minutes, but still remember the contents of a shopping cart. 557 | 558 | ```php 559 | // section will expire after 20 minutes 560 | $section->setExpiration('20 minutes'); 561 | 562 | // variable $section->flash will expire after 30 seconds 563 | $section->setExpiration('30 seconds', 'flash'); 564 | ``` 565 | 566 | The cancellation of the previously set expiration can be achieved by the method `removeExpiration()`. Immediate deletion of the whole section will be ensured by the method `remove()`. 567 | 568 | 569 | 570 | Session Management 571 | ------------------ 572 | 573 | Overview of methods of the `Nette\Http\Session` class for session management: 574 | 575 | start(): void 576 | ------------- 577 | Starts a session. 578 | 579 | isStarted(): bool 580 | ----------------- 581 | Is the session started? 582 | 583 | close(): void 584 | ------------- 585 | Ends the session. The session ends automatically at the end of the script. 586 | 587 | destroy(): void 588 | --------------- 589 | Ends and deletes the session. 590 | 591 | exists(): bool 592 | -------------- 593 | Does the HTTP request contain a cookie with a session ID? 594 | 595 | regenerateId(): void 596 | -------------------- 597 | Generates a new random session ID. Data remain unchanged. 598 | 599 | getId(): string 600 | --------------- 601 | Returns the session ID. 602 | 603 | 604 | Configuration 605 | ------------- 606 | 607 | Methods must be called before starting a session. 608 | 609 | setName(string $name): static 610 | ----------------------------- 611 | Changes the session name. It is possible to run several different sessions at the same time within one website, each under a different name. 612 | 613 | getName(): string 614 | ----------------- 615 | Returns the session name. 616 | 617 | setOptions(array $options): static 618 | ---------------------------------- 619 | Configures the session. It is possible to set all PHP [session directives](https://www.php.net/manual/en/session.configuration.php) (in camelCase format, eg write `savePath` instead of `session.save_path`) and also [readAndClose](https://www.php.net/manual/en/function.session-start.php#refsect1-function.session-start-parameters). 620 | 621 | setExpiration(?string $time): static 622 | ------------------------------------ 623 | Sets the time of inactivity after which the session expires. 624 | 625 | setCookieParameters(string $path, string $domain = null, bool $secure = null, string $samesite = null): static 626 | -------------------------------------------------------------------------------------------------------------- 627 | Sets parameters for cookies. 628 | 629 | setSavePath(string $path): static 630 | --------------------------------- 631 | Sets the directory where session files are stored. 632 | 633 | setHandler(\SessionHandlerInterface $handler): static 634 | ----------------------------------------------------- 635 | Sets custom handler, see [PHP documentation](https://www.php.net/manual/en/class.sessionhandlerinterface.php). 636 | 637 | 638 | Safety First 639 | ------------ 640 | 641 | The server assumes that it communicates with the same user as long as requests contain the same session ID. The task of security mechanisms is to ensure that this behavior really works and that there is no possibility to substitute or steal an identifier. 642 | 643 | That's why Nette Framework properly configures PHP directives to transfer session ID only in cookies, to avoid access from JavaScript and to ignore the identifiers in the URL. Moreover in critical moments, such as user login, it generates a new Session ID. 644 | 645 | Function ini_set is used for configuring PHP, but unfortunately, its use is prohibited at some web hosting services. If it's your case, try to ask your hosting provider to allow this function for you, or at least to configure his server properly. .[note] 646 | 647 | 648 | 649 | Url 650 | === 651 | 652 | The [Nette\Http\Url](https://api.nette.org/3.0/Nette/Http/Url.html) class makes it easy to work with the URL and its individual components, which are outlined in this diagram: 653 | 654 | ``` 655 | scheme user password host port path query fragment 656 | | | | | | | | | 657 | /--\ /--\ /------\ /-------\ /--\/----------\ /--------\ /----\ 658 | http://john:xyz%2A12@nette.org:8080/en/download?name=param#footer 659 | \______\__________________________/ 660 | | | 661 | hostUrl authority 662 | ``` 663 | 664 | URL generation is intuitive: 665 | 666 | ```php 667 | use Nette\Http\Url; 668 | 669 | $url = new Url; 670 | $url->setScheme('https') 671 | ->setHost('localhost') 672 | ->setPath('/edit') 673 | ->setQueryParameter('foo', 'bar'); 674 | 675 | echo $url; // 'https://localhost/edit?foo=bar' 676 | ``` 677 | 678 | You can also parse the URL and then manipulate it: 679 | 680 | ```php 681 | $url = new Url( 682 | 'http://john:xyz%2A12@nette.org:8080/en/download?name=param#footer' 683 | ); 684 | ``` 685 | 686 | The following methods are available to get or change individual URL components: 687 | 688 | Setter | Getter | Returned value 689 | ----------------------------------------|-------------------------------|------------------ 690 | `setScheme(string $scheme)` | `getScheme(): string` | `'http'` 691 | `setUser(string $user)` | `getUser(): string` | `'john'` 692 | `setPassword(string $password)` | `getPassword(): string` | `'xyz*12'` 693 | `setHost(string $host)` | `getHost(): string` | `'nette.org'` 694 | `setPort(int $port)` | `getPort(): ?int` | `8080` 695 | `setPath(string $path)` | `getPath(): string` | `'/en/download'` 696 | `setQuery(string\|array $query)` | `getQuery(): string` | `'name=param'` 697 | `setFragment(string $fragment)` | `getFragment(): string` | `'footer'` 698 | -- | `getAuthority(): string` | `'nette.org:8080'` 699 | -- | `getHostUrl(): string` | `'http://nette.org:8080'` 700 | -- | `getAbsoluteUrl(): string` | full URL 701 | 702 | We can also operate with individual query parameters using: 703 | 704 | Setter | Getter 705 | ----------------------------------------|--------- 706 | `setQuery(string\|array $query)` | `getQueryParameters(): array` 707 | `setQueryParameter(string $name, $val)` | `getQueryParameter(string $name)` 708 | 709 | Method `getDomain(int $level = 2)` returns the right or left part of the host. This is how it works if the host is `www.nette.org`: 710 | 711 | Usage | Result 712 | ----------------------------------------|--------- 713 | `getDomain(1)` | `'org'` 714 | `getDomain(2)` | `'nette.org'` 715 | `getDomain(3)` | `'www.nette.org'` 716 | `getDomain(0)` | `'www.nette.org'` 717 | `getDomain(-1)` | `'www.nette'` 718 | `getDomain(-2)` | `'www'` 719 | `getDomain(-3)` | `''` 720 | 721 | 722 | The `Url` class implements the `JsonSerializable` interface and has a `__toString()` method so that the object can be printed or used in data passed to `json_encode()`. 723 | 724 | ```php 725 | echo $url; 726 | echo json_encode([$url]); 727 | ``` 728 | 729 | Method `isEqual(string|Url $anotherUrl): bool` tests whether the two URLs are identical. 730 | 731 | ```php 732 | $url->isEqual('https://nette.org'); 733 | ``` 734 | 735 | 736 | UrlImmutable 737 | ============ 738 | 739 | The class [Nette\Http\UrlImmutable](https://api.nette.org/3.0/Nette/Http/UrlImmutable.html) is an immutable alternative to class `Url` (just as in PHP `DateTimeImmutable` is immutable alternative to `DateTime`). Instead of setters, it has so-called withers, which do not change the object, but return new instances with a modified value: 740 | 741 | ```php 742 | use Nette\Http\UrlImmutable; 743 | 744 | $url = new UrlImmutable( 745 | 'http://john:xyz%2A12@nette.org:8080/en/download?name=param#footer' 746 | ); 747 | 748 | $newUrl = $url 749 | ->withUser('') 750 | ->withPassword('') 751 | ->withPath('/cs/'); 752 | 753 | echo $newUrl; // 'http://nette.org:8080/cs/?name=param#footer' 754 | ``` 755 | 756 | The following methods are available to get or change individual URL components: 757 | 758 | Wither | Getter | Returned value 759 | ----------------------------------------|-------------------------------|------------------ 760 | `withScheme(string $scheme)` | `getScheme(): string` | `'http'` 761 | `withUser(string $user)` | `getUser(): string` | `'john'` 762 | `withPassword(string $password)` | `getPassword(): string` | `'xyz*12'` 763 | `withHost(string $host)` | `getHost(): string` | `'nette.org'` 764 | `withPort(int $port)` | `getPort(): ?int` | `8080` 765 | `withPath(string $path)` | `getPath(): string` | `'/en/download'` 766 | `withQuery(string\|array $query)` | `getQuery(): string` | `'name=param'` 767 | `withFragment(string $fragment)` | `getFragment(): string` | `'footer'` 768 | -- | `getAuthority(): string` | `'nette.org:8080'` 769 | -- | `getHostUrl(): string` | `'http://nette.org:8080'` 770 | -- | `getAbsoluteUrl(): string` | full URL 771 | 772 | We can also operate with individual query parameters using: 773 | 774 | Wither | Getter 775 | ------------------------------------|--------- 776 | `withQuery(string\|array $query)` | `getQueryParameters(): array` 777 | -- | `getQueryParameter(string $name)` 778 | 779 | The `getDomain(int $level = 2)` method works the same as the method in `Url`. Method `withoutUserInfo()` removes `user` and `password`. 780 | 781 | The `UrlImmutable` class implements the `JsonSerializable` interface and has a `__toString()` method so that the object can be printed or used in data passed to `json_encode()`. 782 | 783 | ```php 784 | echo $url; 785 | echo json_encode([$url]); 786 | ``` 787 | 788 | Method `isEqual(string|Url $anotherUrl): bool` tests whether the two URLs are identical. 789 | 790 | 791 | If you like Nette, **[please make a donation now](https://github.com/sponsors/dg)**. Thank you! 792 | -------------------------------------------------------------------------------- /src/Bridges/HttpDI/HttpExtension.php: -------------------------------------------------------------------------------- 1 | Expect::anyOf(Expect::arrayOf('string'), Expect::string()->castTo('array'))->firstIsDefault()->dynamic(), 31 | 'headers' => Expect::arrayOf('scalar|null')->default([ 32 | 'X-Powered-By' => 'Nette Framework 3', 33 | 'Content-Type' => 'text/html; charset=utf-8', 34 | ])->mergeDefaults(), 35 | 'frames' => Expect::anyOf(Expect::string(), Expect::bool(), null)->default('SAMEORIGIN'), // X-Frame-Options 36 | 'csp' => Expect::arrayOf('array|scalar|null'), // Content-Security-Policy 37 | 'cspReportOnly' => Expect::arrayOf('array|scalar|null'), // Content-Security-Policy-Report-Only 38 | 'featurePolicy' => Expect::arrayOf('array|scalar|null'), // Feature-Policy 39 | 'cookiePath' => Expect::string()->dynamic(), 40 | 'cookieDomain' => Expect::string()->dynamic(), 41 | 'cookieSecure' => Expect::anyOf('auto', null, true, false)->firstIsDefault()->dynamic(), // Whether the cookie is available only through HTTPS 42 | 'disableNetteCookie' => Expect::bool(false), // disables cookie use by Nette 43 | ]); 44 | } 45 | 46 | 47 | public function loadConfiguration(): void 48 | { 49 | $builder = $this->getContainerBuilder(); 50 | $config = $this->config; 51 | 52 | $builder->addDefinition($this->prefix('requestFactory')) 53 | ->setFactory(Nette\Http\RequestFactory::class) 54 | ->addSetup('setProxy', [$config->proxy]); 55 | 56 | $request = $builder->addDefinition($this->prefix('request')) 57 | ->setFactory('@Nette\Http\RequestFactory::fromGlobals'); 58 | 59 | $response = $builder->addDefinition($this->prefix('response')) 60 | ->setFactory(Nette\Http\Response::class); 61 | 62 | if ($config->cookiePath !== null) { 63 | $response->addSetup('$cookiePath', [$config->cookiePath]); 64 | } 65 | 66 | if ($config->cookieDomain !== null) { 67 | $value = $config->cookieDomain === 'domain' 68 | ? $builder::literal('$this->getService(?)->getUrl()->getDomain(2)', [$request->getName()]) 69 | : $config->cookieDomain; 70 | $response->addSetup('$cookieDomain', [$value]); 71 | } 72 | 73 | if ($config->cookieSecure !== null) { 74 | $value = $config->cookieSecure === 'auto' 75 | ? $builder::literal('$this->getService(?)->isSecured()', [$request->getName()]) 76 | : $config->cookieSecure; 77 | $response->addSetup('$cookieSecure', [$value]); 78 | } 79 | 80 | if ($this->name === 'http') { 81 | $builder->addAlias('nette.httpRequestFactory', $this->prefix('requestFactory')); 82 | $builder->addAlias('httpRequest', $this->prefix('request')); 83 | $builder->addAlias('httpResponse', $this->prefix('response')); 84 | } 85 | 86 | if (!$this->cliMode) { 87 | $this->sendHeaders(); 88 | } 89 | } 90 | 91 | 92 | private function sendHeaders(): void 93 | { 94 | $config = $this->config; 95 | $headers = array_map('strval', $config->headers); 96 | 97 | if (isset($config->frames) && $config->frames !== true && !isset($headers['X-Frame-Options'])) { 98 | $frames = $config->frames; 99 | if ($frames === false) { 100 | $frames = 'DENY'; 101 | } elseif (preg_match('#^https?:#', $frames)) { 102 | $frames = "ALLOW-FROM $frames"; 103 | } 104 | 105 | $headers['X-Frame-Options'] = $frames; 106 | } 107 | 108 | foreach (['csp', 'cspReportOnly'] as $key) { 109 | if (empty($config->$key)) { 110 | continue; 111 | } 112 | 113 | $value = self::buildPolicy($config->$key); 114 | if (str_contains($value, "'nonce'")) { 115 | $this->initialization->addBody('$cspNonce = base64_encode(random_bytes(16));'); 116 | $value = Nette\DI\ContainerBuilder::literal( 117 | 'str_replace(?, ? . $cspNonce, ?)', 118 | ["'nonce", "'nonce-", $value], 119 | ); 120 | } 121 | 122 | $headers['Content-Security-Policy' . ($key === 'csp' ? '' : '-Report-Only')] = $value; 123 | } 124 | 125 | if (!empty($config->featurePolicy)) { 126 | $headers['Feature-Policy'] = self::buildPolicy($config->featurePolicy); 127 | } 128 | 129 | $this->initialization->addBody('$response = $this->getService(?);', [$this->prefix('response')]); 130 | foreach ($headers as $key => $value) { 131 | if ($value !== '') { 132 | $this->initialization->addBody('$response->setHeader(?, ?);', [$key, $value]); 133 | } 134 | } 135 | 136 | if (!$config->disableNetteCookie) { 137 | $this->initialization->addBody( 138 | 'Nette\Http\Helpers::initCookie($this->getService(?), $response);', 139 | [$this->prefix('request')], 140 | ); 141 | } 142 | } 143 | 144 | 145 | private static function buildPolicy(array $config): string 146 | { 147 | $nonQuoted = ['require-sri-for' => 1, 'sandbox' => 1]; 148 | $value = ''; 149 | foreach ($config as $type => $policy) { 150 | if ($policy === false) { 151 | continue; 152 | } 153 | 154 | $policy = $policy === true ? [] : (array) $policy; 155 | $value .= $type; 156 | foreach ($policy as $item) { 157 | if (is_array($item)) { 158 | $item = key($item) . ':'; 159 | } 160 | 161 | $value .= !isset($nonQuoted[$type]) && preg_match('#^[a-z-]+$#D', $item) 162 | ? " '$item'" 163 | : " $item"; 164 | } 165 | 166 | $value .= '; '; 167 | } 168 | 169 | return $value; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/Bridges/HttpDI/SessionExtension.php: -------------------------------------------------------------------------------- 1 | Expect::bool(false), 33 | 'autoStart' => Expect::anyOf('smart', 'always', 'never', true, false)->firstIsDefault(), 34 | 'expiration' => Expect::string()->dynamic(), 35 | 'handler' => Expect::string()->dynamic(), 36 | 'readAndClose' => Expect::bool(), 37 | 'cookieSamesite' => Expect::anyOf(IResponse::SameSiteLax, IResponse::SameSiteStrict, IResponse::SameSiteNone) 38 | ->firstIsDefault(), 39 | ])->otherItems('mixed'); 40 | } 41 | 42 | 43 | public function loadConfiguration(): void 44 | { 45 | $builder = $this->getContainerBuilder(); 46 | $config = $this->config; 47 | 48 | $session = $builder->addDefinition($this->prefix('session')) 49 | ->setFactory(Nette\Http\Session::class); 50 | 51 | if ($config->expiration) { 52 | $session->addSetup('setExpiration', [$config->expiration]); 53 | } 54 | 55 | if ($config->handler) { 56 | $session->addSetup('setHandler', [$config->handler]); 57 | } 58 | 59 | if (($config->cookieDomain ?? null) === 'domain') { 60 | $config->cookieDomain = $builder::literal('$this->getByType(Nette\Http\IRequest::class)->getUrl()->getDomain(2)'); 61 | } 62 | 63 | $this->compiler->addExportedType(Nette\Http\IRequest::class); 64 | 65 | if ($this->debugMode && $config->debugger) { 66 | $session->addSetup('@Tracy\Bar::addPanel', [ 67 | new Nette\DI\Definitions\Statement(Nette\Bridges\HttpTracy\SessionPanel::class), 68 | ]); 69 | } 70 | 71 | $options = (array) $config; 72 | unset($options['expiration'], $options['handler'], $options['autoStart'], $options['debugger']); 73 | if ($config->autoStart === 'never') { 74 | $options['autoStart'] = false; 75 | } 76 | 77 | if ($config->readAndClose === null) { 78 | unset($options['readAndClose']); 79 | } 80 | 81 | if (!empty($options)) { 82 | $session->addSetup('setOptions', [$options]); 83 | } 84 | 85 | if ($this->name === 'session') { 86 | $builder->addAlias('session', $this->prefix('session')); 87 | } 88 | 89 | if (!$this->cliMode) { 90 | $name = $this->prefix('session'); 91 | 92 | if ($config->autoStart === 'smart') { 93 | $this->initialization->addBody('$this->getService(?)->autoStart(false);', [$name]); 94 | 95 | } elseif ($config->autoStart === 'always' || $config->autoStart === true) { 96 | $this->initialization->addBody('$this->getService(?)->start();', [$name]); 97 | } 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Bridges/HttpTracy/SessionPanel.php: -------------------------------------------------------------------------------- 1 | 4 | 12 | 13 |

Session # 14 | … (Lifetime: 15 | )

16 | 17 |
18 |

empty

19 | 20 | $v): ?> 21 | 22 | 24 | 25 | 26 | 28 | 30 | 31 |
Nette Session true]) ?> 23 |
27 | true]) ?> 29 |
32 |
33 | -------------------------------------------------------------------------------- /src/Bridges/HttpTracy/dist/tab.phtml: -------------------------------------------------------------------------------- 1 | 4 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/Bridges/HttpTracy/panel.latte: -------------------------------------------------------------------------------- 1 | 9 | 10 |

Session #{substr(session_id(), 0, 10)}… (Lifetime: {ini_get('session.cookie_lifetime')})

11 | 12 |
13 | {if empty($_SESSION)} 14 |

empty

15 | {else} 16 | 17 | {foreach $_SESSION as $k => $v} 18 | {if $k === __NF} 19 | 20 | 21 | 22 | 23 | {elseif $k !== '_tracy'} 24 | 25 | 26 | 27 | 28 | {/if} 29 | {/foreach} 30 |
Nette Session{Tracy\Dumper::toHtml($v[DATA] ?? null, [Tracy\Dumper::LIVE => true])}
{$k}{Tracy\Dumper::toHtml($v, [Tracy\Dumper::LIVE => true])}
31 | {/if} 32 |
33 | -------------------------------------------------------------------------------- /src/Bridges/HttpTracy/tab.latte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/Http/Context.php: -------------------------------------------------------------------------------- 1 | response->setHeader('Last-Modified', Helpers::formatDate($lastModified)); 32 | } 33 | 34 | if ($etag) { 35 | $this->response->setHeader('ETag', '"' . addslashes($etag) . '"'); 36 | } 37 | 38 | $ifNoneMatch = $this->request->getHeader('If-None-Match'); 39 | if ($ifNoneMatch === '*') { 40 | $match = true; // match, check if-modified-since 41 | 42 | } elseif ($ifNoneMatch !== null) { 43 | $etag = $this->response->getHeader('ETag'); 44 | 45 | if ($etag === null || !str_contains(' ' . strtr($ifNoneMatch, ",\t", ' '), ' ' . $etag)) { 46 | return true; 47 | 48 | } else { 49 | $match = true; // match, check if-modified-since 50 | } 51 | } 52 | 53 | $ifModifiedSince = $this->request->getHeader('If-Modified-Since'); 54 | if ($ifModifiedSince !== null) { 55 | $lastModified = $this->response->getHeader('Last-Modified'); 56 | if ($lastModified !== null && strtotime($lastModified) <= strtotime($ifModifiedSince)) { 57 | $match = true; 58 | 59 | } else { 60 | return true; 61 | } 62 | } 63 | 64 | if (empty($match)) { 65 | return true; 66 | } 67 | 68 | $this->response->setCode(IResponse::S304_NotModified); 69 | return false; 70 | } 71 | 72 | 73 | public function getRequest(): IRequest 74 | { 75 | return $this->request; 76 | } 77 | 78 | 79 | public function getResponse(): IResponse 80 | { 81 | return $this->response; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Http/FileUpload.php: -------------------------------------------------------------------------------- 1 | basename($value), 50 | 'full_path' => $value, 51 | 'size' => filesize($value), 52 | 'tmp_name' => $value, 53 | 'error' => UPLOAD_ERR_OK, 54 | ]; 55 | } 56 | 57 | $this->name = $value['name'] ?? ''; 58 | $this->fullPath = $value['full_path'] ?? null; 59 | $this->size = $value['size'] ?? 0; 60 | $this->tmpName = $value['tmp_name'] ?? ''; 61 | $this->error = $value['error'] ?? UPLOAD_ERR_NO_FILE; 62 | } 63 | 64 | 65 | #[\Deprecated('use getUntrustedName()')] 66 | public function getName(): string 67 | { 68 | trigger_error(__METHOD__ . '() is deprecated, use getUntrustedName()', E_USER_DEPRECATED); 69 | return $this->name; 70 | } 71 | 72 | 73 | /** 74 | * Returns the original file name as submitted by the browser. Do not trust the value returned by this method. 75 | * A client could send a malicious filename with the intention to corrupt or hack your application. 76 | */ 77 | public function getUntrustedName(): string 78 | { 79 | return $this->name; 80 | } 81 | 82 | 83 | /** 84 | * Returns the sanitized file name. The resulting name contains only ASCII characters [a-zA-Z0-9.-]. 85 | * If the name does not contain such characters, it returns 'unknown'. If the file is an image supported by PHP, 86 | * it returns the correct file extension. Do not blindly trust the value returned by this method. 87 | */ 88 | public function getSanitizedName(): string 89 | { 90 | $name = Nette\Utils\Strings::webalize($this->name, '.', lower: false); 91 | $name = str_replace(['-.', '.-'], '.', $name); 92 | $name = trim($name, '.-'); 93 | $name = $name === '' ? 'unknown' : $name; 94 | if ($this->isImage()) { 95 | $name = preg_replace('#\.[^.]+$#D', '', $name); 96 | $name .= '.' . $this->getSuggestedExtension(); 97 | } 98 | 99 | return $name; 100 | } 101 | 102 | 103 | /** 104 | * Returns the original full path as submitted by the browser during directory upload. Do not trust the value 105 | * returned by this method. A client could send a malicious directory structure with the intention to corrupt 106 | * or hack your application. 107 | * 108 | * The full path is only available in PHP 8.1 and above. In previous versions, this method returns the file name. 109 | */ 110 | public function getUntrustedFullPath(): string 111 | { 112 | return $this->fullPath ?? $this->name; 113 | } 114 | 115 | 116 | /** 117 | * Detects the MIME content type of the uploaded file based on its signature. Requires PHP extension fileinfo. 118 | * If the upload was not successful or the detection failed, it returns null. 119 | */ 120 | public function getContentType(): ?string 121 | { 122 | if ($this->isOk()) { 123 | $this->type ??= finfo_file(finfo_open(FILEINFO_MIME_TYPE), $this->tmpName); 124 | } 125 | 126 | return $this->type ?: null; 127 | } 128 | 129 | 130 | /** 131 | * Returns the appropriate file extension (without the period) corresponding to the detected MIME type. Requires the PHP extension fileinfo. 132 | */ 133 | public function getSuggestedExtension(): ?string 134 | { 135 | if ($this->isOk() && $this->extension === null) { 136 | $exts = finfo_file(finfo_open(FILEINFO_EXTENSION), $this->tmpName); 137 | if ($exts && $exts !== '???') { 138 | return $this->extension = preg_replace('~[/,].*~', '', $exts); 139 | } 140 | [, , $type] = @getimagesize($this->tmpName); // @ - files smaller than 12 bytes causes read error 141 | if ($type) { 142 | return $this->extension = image_type_to_extension($type, false); 143 | } 144 | $this->extension = false; 145 | } 146 | 147 | return $this->extension ?: null; 148 | } 149 | 150 | 151 | /** 152 | * Returns the size of the uploaded file in bytes. 153 | */ 154 | public function getSize(): int 155 | { 156 | return $this->size; 157 | } 158 | 159 | 160 | /** 161 | * Returns the path of the temporary location of the uploaded file. 162 | */ 163 | public function getTemporaryFile(): string 164 | { 165 | return $this->tmpName; 166 | } 167 | 168 | 169 | /** 170 | * Returns the path of the temporary location of the uploaded file. 171 | */ 172 | public function __toString(): string 173 | { 174 | return $this->tmpName; 175 | } 176 | 177 | 178 | /** 179 | * Returns the error code. It has to be one of UPLOAD_ERR_XXX constants. 180 | * @see http://php.net/manual/en/features.file-upload.errors.php 181 | */ 182 | public function getError(): int 183 | { 184 | return $this->error; 185 | } 186 | 187 | 188 | /** 189 | * Returns true if the file was uploaded successfully. 190 | */ 191 | public function isOk(): bool 192 | { 193 | return $this->error === UPLOAD_ERR_OK; 194 | } 195 | 196 | 197 | /** 198 | * Returns true if the user has uploaded a file. 199 | */ 200 | public function hasFile(): bool 201 | { 202 | return $this->error !== UPLOAD_ERR_NO_FILE; 203 | } 204 | 205 | 206 | /** 207 | * Moves an uploaded file to a new location. If the destination file already exists, it will be overwritten. 208 | */ 209 | public function move(string $dest): static 210 | { 211 | $dir = dirname($dest); 212 | Nette\Utils\FileSystem::createDir($dir); 213 | @unlink($dest); // @ - file may not exists 214 | Nette\Utils\Callback::invokeSafe( 215 | is_uploaded_file($this->tmpName) ? 'move_uploaded_file' : 'rename', 216 | [$this->tmpName, $dest], 217 | function (string $message) use ($dest): void { 218 | throw new Nette\InvalidStateException("Unable to move uploaded file '$this->tmpName' to '$dest'. $message"); 219 | }, 220 | ); 221 | @chmod($dest, 0o666); // @ - possible low permission to chmod 222 | $this->tmpName = $dest; 223 | return $this; 224 | } 225 | 226 | 227 | /** 228 | * Returns true if the uploaded file is an image and the format is supported by PHP, so it can be loaded using the toImage() method. 229 | * Detection is based on its signature, the integrity of the file is not checked. Requires PHP extensions fileinfo & gd. 230 | */ 231 | public function isImage(): bool 232 | { 233 | $types = array_map(fn($type) => Image::typeToMimeType($type), Image::getSupportedTypes()); 234 | return in_array($this->getContentType(), $types, strict: true); 235 | } 236 | 237 | 238 | /** 239 | * Converts uploaded image to Nette\Utils\Image object. 240 | * @throws Nette\Utils\ImageException If the upload was not successful or is not a valid image 241 | */ 242 | public function toImage(): Image 243 | { 244 | return Image::fromFile($this->tmpName); 245 | } 246 | 247 | 248 | /** 249 | * Returns a pair of [width, height] with dimensions of the uploaded image. 250 | */ 251 | public function getImageSize(): ?array 252 | { 253 | return $this->isImage() 254 | ? array_intersect_key(getimagesize($this->tmpName), [0, 1]) 255 | : null; 256 | } 257 | 258 | 259 | /** 260 | * Returns image file extension based on detected content type (without dot). 261 | * @deprecated use getSuggestedExtension() 262 | */ 263 | public function getImageFileExtension(): ?string 264 | { 265 | return $this->getSuggestedExtension(); 266 | } 267 | 268 | 269 | /** 270 | * Returns the contents of the uploaded file. If the upload was not successful, it returns null. 271 | */ 272 | public function getContents(): ?string 273 | { 274 | // future implementation can try to work around safe_mode and open_basedir limitations 275 | return $this->isOk() 276 | ? file_get_contents($this->tmpName) 277 | : null; 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /src/Http/Helpers.php: -------------------------------------------------------------------------------- 1 | setTimezone(new \DateTimeZone('GMT')); 33 | return $time->format('D, d M Y H:i:s \G\M\T'); 34 | } 35 | 36 | 37 | /** 38 | * Is IP address in CIDR block? 39 | */ 40 | public static function ipMatch(string $ip, string $mask): bool 41 | { 42 | [$mask, $size] = explode('/', $mask . '/'); 43 | $tmp = fn(int $n): string => sprintf('%032b', $n); 44 | $ip = implode('', array_map($tmp, unpack('N*', inet_pton($ip)))); 45 | $mask = implode('', array_map($tmp, unpack('N*', inet_pton($mask)))); 46 | $max = strlen($ip); 47 | if (!$max || $max !== strlen($mask) || (int) $size < 0 || (int) $size > $max) { 48 | return false; 49 | } 50 | 51 | return strncmp($ip, $mask, $size === '' ? $max : (int) $size) === 0; 52 | } 53 | 54 | 55 | public static function initCookie(IRequest $request, IResponse $response) 56 | { 57 | $response->setCookie(self::StrictCookieName, '1', 0, '/', sameSite: IResponse::SameSiteStrict); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Http/IRequest.php: -------------------------------------------------------------------------------- 1 | 'Continue', 83 | 101 => 'Switching Protocols', 84 | 102 => 'Processing', 85 | 200 => 'OK', 86 | 201 => 'Created', 87 | 202 => 'Accepted', 88 | 203 => 'Non-Authoritative Information', 89 | 204 => 'No Content', 90 | 205 => 'Reset Content', 91 | 206 => 'Partial Content', 92 | 207 => 'Multi-status', 93 | 208 => 'Already Reported', 94 | 226 => 'IM Used', 95 | 300 => 'Multiple Choices', 96 | 301 => 'Moved Permanently', 97 | 302 => 'Found', 98 | 303 => 'See Other', 99 | 304 => 'Not Modified', 100 | 305 => 'Use Proxy', 101 | 307 => 'Temporary Redirect', 102 | 308 => 'Permanent Redirect', 103 | 400 => 'Bad Request', 104 | 401 => 'Unauthorized', 105 | 402 => 'Payment Required', 106 | 403 => 'Forbidden', 107 | 404 => 'Not Found', 108 | 405 => 'Method Not Allowed', 109 | 406 => 'Not Acceptable', 110 | 407 => 'Proxy Authentication Required', 111 | 408 => 'Request Time-out', 112 | 409 => 'Conflict', 113 | 410 => 'Gone', 114 | 411 => 'Length Required', 115 | 412 => 'Precondition Failed', 116 | 413 => 'Request Entity Too Large', 117 | 414 => 'Request-URI Too Large', 118 | 415 => 'Unsupported Media Type', 119 | 416 => 'Requested range not satisfiable', 120 | 417 => 'Expectation Failed', 121 | 421 => 'Misdirected Request', 122 | 422 => 'Unprocessable Entity', 123 | 423 => 'Locked', 124 | 424 => 'Failed Dependency', 125 | 426 => 'Upgrade Required', 126 | 428 => 'Precondition Required', 127 | 429 => 'Too Many Requests', 128 | 431 => 'Request Header Fields Too Large', 129 | 451 => 'Unavailable For Legal Reasons', 130 | 500 => 'Internal Server Error', 131 | 501 => 'Not Implemented', 132 | 502 => 'Bad Gateway', 133 | 503 => 'Service Unavailable', 134 | 504 => 'Gateway Time-out', 135 | 505 => 'HTTP Version not supported', 136 | 506 => 'Variant Also Negotiates', 137 | 507 => 'Insufficient Storage', 138 | 508 => 'Loop Detected', 139 | 510 => 'Not Extended', 140 | 511 => 'Network Authentication Required', 141 | ]; 142 | 143 | /** SameSite cookie */ 144 | public const 145 | SameSiteLax = 'Lax', 146 | SameSiteStrict = 'Strict', 147 | SameSiteNone = 'None'; 148 | 149 | #[\Deprecated('use IResponse::ReasonPhrases')] 150 | public const REASON_PHRASES = self::ReasonPhrases; 151 | 152 | #[\Deprecated('use IResponse::SameSiteLax')] 153 | public const SAME_SITE_LAX = self::SameSiteLax; 154 | 155 | #[\Deprecated('use IResponse::SameSiteStrict')] 156 | public const SAME_SITE_STRICT = self::SameSiteStrict; 157 | 158 | #[\Deprecated('use IResponse::SameSiteNone')] 159 | public const SAME_SITE_NONE = self::SameSiteNone; 160 | 161 | #[\Deprecated('use IResponse::S100_Continue')] 162 | public const S100_CONTINUE = self::S100_Continue; 163 | 164 | #[\Deprecated('use IResponse::S101_SwitchingProtocols')] 165 | public const S101_SWITCHING_PROTOCOLS = self::S101_SwitchingProtocols; 166 | 167 | #[\Deprecated('use IResponse::S102_Processing')] 168 | public const S102_PROCESSING = self::S102_Processing; 169 | 170 | #[\Deprecated('use IResponse::S201_Created')] 171 | public const S201_CREATED = self::S201_Created; 172 | 173 | #[\Deprecated('use IResponse::S202_Accepted')] 174 | public const S202_ACCEPTED = self::S202_Accepted; 175 | 176 | #[\Deprecated('use IResponse::S203_NonAuthoritativeInformation')] 177 | public const S203_NON_AUTHORITATIVE_INFORMATION = self::S203_NonAuthoritativeInformation; 178 | 179 | #[\Deprecated('use IResponse::S204_NoContent')] 180 | public const S204_NO_CONTENT = self::S204_NoContent; 181 | 182 | #[\Deprecated('use IResponse::S205_ResetContent')] 183 | public const S205_RESET_CONTENT = self::S205_ResetContent; 184 | 185 | #[\Deprecated('use IResponse::S206_PartialContent')] 186 | public const S206_PARTIAL_CONTENT = self::S206_PartialContent; 187 | 188 | #[\Deprecated('use IResponse::S207_MultiStatus')] 189 | public const S207_MULTI_STATUS = self::S207_MultiStatus; 190 | 191 | #[\Deprecated('use IResponse::S208_AlreadyReported')] 192 | public const S208_ALREADY_REPORTED = self::S208_AlreadyReported; 193 | 194 | #[\Deprecated('use IResponse::S226_ImUsed')] 195 | public const S226_IM_USED = self::S226_ImUsed; 196 | 197 | #[\Deprecated('use IResponse::S300_MultipleChoices')] 198 | public const S300_MULTIPLE_CHOICES = self::S300_MultipleChoices; 199 | 200 | #[\Deprecated('use IResponse::S301_MovedPermanently')] 201 | public const S301_MOVED_PERMANENTLY = self::S301_MovedPermanently; 202 | 203 | #[\Deprecated('use IResponse::S302_Found')] 204 | public const S302_FOUND = self::S302_Found; 205 | 206 | #[\Deprecated('use IResponse::S303_PostGet')] 207 | public const S303_SEE_OTHER = self::S303_PostGet; 208 | 209 | #[\Deprecated('use IResponse::S303_PostGet')] 210 | public const S303_POST_GET = self::S303_PostGet; 211 | 212 | #[\Deprecated('use IResponse::S304_NotModified')] 213 | public const S304_NOT_MODIFIED = self::S304_NotModified; 214 | 215 | #[\Deprecated('use IResponse::S305_UseProxy')] 216 | public const S305_USE_PROXY = self::S305_UseProxy; 217 | 218 | #[\Deprecated('use IResponse::S307_TemporaryRedirect')] 219 | public const S307_TEMPORARY_REDIRECT = self::S307_TemporaryRedirect; 220 | 221 | #[\Deprecated('use IResponse::S308_PermanentRedirect')] 222 | public const S308_PERMANENT_REDIRECT = self::S308_PermanentRedirect; 223 | 224 | #[\Deprecated('use IResponse::S400_BadRequest')] 225 | public const S400_BAD_REQUEST = self::S400_BadRequest; 226 | 227 | #[\Deprecated('use IResponse::S401_Unauthorized')] 228 | public const S401_UNAUTHORIZED = self::S401_Unauthorized; 229 | 230 | #[\Deprecated('use IResponse::S402_PaymentRequired')] 231 | public const S402_PAYMENT_REQUIRED = self::S402_PaymentRequired; 232 | 233 | #[\Deprecated('use IResponse::S403_Forbidden')] 234 | public const S403_FORBIDDEN = self::S403_Forbidden; 235 | 236 | #[\Deprecated('use IResponse::S404_NotFound')] 237 | public const S404_NOT_FOUND = self::S404_NotFound; 238 | 239 | #[\Deprecated('use IResponse::S405_MethodNotAllowed')] 240 | public const S405_METHOD_NOT_ALLOWED = self::S405_MethodNotAllowed; 241 | 242 | #[\Deprecated('use IResponse::S406_NotAcceptable')] 243 | public const S406_NOT_ACCEPTABLE = self::S406_NotAcceptable; 244 | 245 | #[\Deprecated('use IResponse::S407_ProxyAuthenticationRequired')] 246 | public const S407_PROXY_AUTHENTICATION_REQUIRED = self::S407_ProxyAuthenticationRequired; 247 | 248 | #[\Deprecated('use IResponse::S408_RequestTimeout')] 249 | public const S408_REQUEST_TIMEOUT = self::S408_RequestTimeout; 250 | 251 | #[\Deprecated('use IResponse::S409_Conflict')] 252 | public const S409_CONFLICT = self::S409_Conflict; 253 | 254 | #[\Deprecated('use IResponse::S410_Gone')] 255 | public const S410_GONE = self::S410_Gone; 256 | 257 | #[\Deprecated('use IResponse::S411_LengthRequired')] 258 | public const S411_LENGTH_REQUIRED = self::S411_LengthRequired; 259 | 260 | #[\Deprecated('use IResponse::S412_PreconditionFailed')] 261 | public const S412_PRECONDITION_FAILED = self::S412_PreconditionFailed; 262 | 263 | #[\Deprecated('use IResponse::S413_RequestEntityTooLarge')] 264 | public const S413_REQUEST_ENTITY_TOO_LARGE = self::S413_RequestEntityTooLarge; 265 | 266 | #[\Deprecated('use IResponse::S414_RequestUriTooLong')] 267 | public const S414_REQUEST_URI_TOO_LONG = self::S414_RequestUriTooLong; 268 | 269 | #[\Deprecated('use IResponse::S415_UnsupportedMediaType')] 270 | public const S415_UNSUPPORTED_MEDIA_TYPE = self::S415_UnsupportedMediaType; 271 | 272 | #[\Deprecated('use IResponse::S416_RequestedRangeNotSatisfiable')] 273 | public const S416_REQUESTED_RANGE_NOT_SATISFIABLE = self::S416_RequestedRangeNotSatisfiable; 274 | 275 | #[\Deprecated('use IResponse::S417_ExpectationFailed')] 276 | public const S417_EXPECTATION_FAILED = self::S417_ExpectationFailed; 277 | 278 | #[\Deprecated('use IResponse::S421_MisdirectedRequest')] 279 | public const S421_MISDIRECTED_REQUEST = self::S421_MisdirectedRequest; 280 | 281 | #[\Deprecated('use IResponse::S422_UnprocessableEntity')] 282 | public const S422_UNPROCESSABLE_ENTITY = self::S422_UnprocessableEntity; 283 | 284 | #[\Deprecated('use IResponse::S423_Locked')] 285 | public const S423_LOCKED = self::S423_Locked; 286 | 287 | #[\Deprecated('use IResponse::S424_FailedDependency')] 288 | public const S424_FAILED_DEPENDENCY = self::S424_FailedDependency; 289 | 290 | #[\Deprecated('use IResponse::S426_UpgradeRequired')] 291 | public const S426_UPGRADE_REQUIRED = self::S426_UpgradeRequired; 292 | 293 | #[\Deprecated('use IResponse::S428_PreconditionRequired')] 294 | public const S428_PRECONDITION_REQUIRED = self::S428_PreconditionRequired; 295 | 296 | #[\Deprecated('use IResponse::S429_TooManyRequests')] 297 | public const S429_TOO_MANY_REQUESTS = self::S429_TooManyRequests; 298 | 299 | #[\Deprecated('use IResponse::S431_RequestHeaderFieldsTooLarge')] 300 | public const S431_REQUEST_HEADER_FIELDS_TOO_LARGE = self::S431_RequestHeaderFieldsTooLarge; 301 | 302 | #[\Deprecated('use IResponse::S451_UnavailableForLegalReasons')] 303 | public const S451_UNAVAILABLE_FOR_LEGAL_REASONS = self::S451_UnavailableForLegalReasons; 304 | 305 | #[\Deprecated('use IResponse::S500_InternalServerError')] 306 | public const S500_INTERNAL_SERVER_ERROR = self::S500_InternalServerError; 307 | 308 | #[\Deprecated('use IResponse::S501_NotImplemented')] 309 | public const S501_NOT_IMPLEMENTED = self::S501_NotImplemented; 310 | 311 | #[\Deprecated('use IResponse::S502_BadGateway')] 312 | public const S502_BAD_GATEWAY = self::S502_BadGateway; 313 | 314 | #[\Deprecated('use IResponse::S503_ServiceUnavailable')] 315 | public const S503_SERVICE_UNAVAILABLE = self::S503_ServiceUnavailable; 316 | 317 | #[\Deprecated('use IResponse::S504_GatewayTimeout')] 318 | public const S504_GATEWAY_TIMEOUT = self::S504_GatewayTimeout; 319 | 320 | #[\Deprecated('use IResponse::S505_HttpVersionNotSupported')] 321 | public const S505_HTTP_VERSION_NOT_SUPPORTED = self::S505_HttpVersionNotSupported; 322 | 323 | #[\Deprecated('use IResponse::S506_VariantAlsoNegotiates')] 324 | public const S506_VARIANT_ALSO_NEGOTIATES = self::S506_VariantAlsoNegotiates; 325 | 326 | #[\Deprecated('use IResponse::S507_InsufficientStorage')] 327 | public const S507_INSUFFICIENT_STORAGE = self::S507_InsufficientStorage; 328 | 329 | #[\Deprecated('use IResponse::S508_LoopDetected')] 330 | public const S508_LOOP_DETECTED = self::S508_LoopDetected; 331 | 332 | #[\Deprecated('use IResponse::S510_NotExtended')] 333 | public const S510_NOT_EXTENDED = self::S510_NotExtended; 334 | 335 | #[\Deprecated('use IResponse::S511_NetworkAuthenticationRequired')] 336 | public const S511_NETWORK_AUTHENTICATION_REQUIRED = self::S511_NetworkAuthenticationRequired; 337 | 338 | /** 339 | * Sets HTTP response code. 340 | */ 341 | function setCode(int $code, ?string $reason = null): static; 342 | 343 | /** 344 | * Returns HTTP response code. 345 | */ 346 | function getCode(): int; 347 | 348 | /** 349 | * Sends a HTTP header and replaces a previous one. 350 | */ 351 | function setHeader(string $name, string $value): static; 352 | 353 | /** 354 | * Adds HTTP header. 355 | */ 356 | function addHeader(string $name, string $value): static; 357 | 358 | /** 359 | * Sends a Content-type HTTP header. 360 | */ 361 | function setContentType(string $type, ?string $charset = null): static; 362 | 363 | /** 364 | * Redirects to a new URL. 365 | */ 366 | function redirect(string $url, int $code = self::S302_Found): void; 367 | 368 | /** 369 | * Sets the time (like '20 minutes') before a page cached on a browser expires, null means "must-revalidate". 370 | */ 371 | function setExpiration(?string $expire): static; 372 | 373 | /** 374 | * Checks if headers have been sent. 375 | */ 376 | function isSent(): bool; 377 | 378 | /** 379 | * Returns value of an HTTP header. 380 | */ 381 | function getHeader(string $header): ?string; 382 | 383 | /** 384 | * Returns an associative array of headers to sent. 385 | */ 386 | function getHeaders(): array; 387 | 388 | /** 389 | * Sends a cookie. 390 | */ 391 | function setCookie( 392 | string $name, 393 | string $value, 394 | string|int|\DateTimeInterface|null $expire, 395 | ?string $path = null, 396 | ?string $domain = null, 397 | bool $secure = false, 398 | bool $httpOnly = true, 399 | string $sameSite = self::SameSiteLax, 400 | ): static; 401 | 402 | /** 403 | * Deletes a cookie. 404 | */ 405 | function deleteCookie( 406 | string $name, 407 | ?string $path = null, 408 | ?string $domain = null, 409 | bool $secure = false, 410 | ); 411 | } 412 | -------------------------------------------------------------------------------- /src/Http/Request.php: -------------------------------------------------------------------------------- 1 | headers = array_change_key_case($headers, CASE_LOWER); 53 | $this->rawBodyCallback = $rawBodyCallback ? $rawBodyCallback(...) : null; 54 | } 55 | 56 | 57 | /** 58 | * Returns a clone with a different URL. 59 | */ 60 | public function withUrl(UrlScript $url): static 61 | { 62 | $dolly = clone $this; 63 | $dolly->url = $url; 64 | return $dolly; 65 | } 66 | 67 | 68 | /** 69 | * Returns the URL of the request. 70 | */ 71 | public function getUrl(): UrlScript 72 | { 73 | return $this->url; 74 | } 75 | 76 | 77 | /********************* query, post, files & cookies ****************d*g**/ 78 | 79 | 80 | /** 81 | * Returns variable provided to the script via URL query ($_GET). 82 | * If no key is passed, returns the entire array. 83 | */ 84 | public function getQuery(?string $key = null): mixed 85 | { 86 | if (func_num_args() === 0) { 87 | return $this->url->getQueryParameters(); 88 | } 89 | 90 | return $this->url->getQueryParameter($key); 91 | } 92 | 93 | 94 | /** 95 | * Returns variable provided to the script via POST method ($_POST). 96 | * If no key is passed, returns the entire array. 97 | */ 98 | public function getPost(?string $key = null): mixed 99 | { 100 | if (func_num_args() === 0) { 101 | return $this->post; 102 | } 103 | 104 | return $this->post[$key] ?? null; 105 | } 106 | 107 | 108 | /** 109 | * Returns uploaded file. 110 | * @param string|string[] $key 111 | */ 112 | public function getFile($key): ?FileUpload 113 | { 114 | $res = Nette\Utils\Arrays::get($this->files, $key, null); 115 | return $res instanceof FileUpload ? $res : null; 116 | } 117 | 118 | 119 | /** 120 | * Returns tree of upload files in a normalized structure, with each leaf an instance of Nette\Http\FileUpload. 121 | */ 122 | public function getFiles(): array 123 | { 124 | return $this->files; 125 | } 126 | 127 | 128 | /** 129 | * Returns a cookie or `null` if it does not exist. 130 | */ 131 | public function getCookie(string $key): mixed 132 | { 133 | return $this->cookies[$key] ?? null; 134 | } 135 | 136 | 137 | /** 138 | * Returns all cookies. 139 | */ 140 | public function getCookies(): array 141 | { 142 | return $this->cookies; 143 | } 144 | 145 | 146 | /********************* method & headers ****************d*g**/ 147 | 148 | 149 | /** 150 | * Returns the HTTP method with which the request was made (GET, POST, HEAD, PUT, ...). 151 | */ 152 | public function getMethod(): string 153 | { 154 | return $this->method; 155 | } 156 | 157 | 158 | /** 159 | * Checks the HTTP method with which the request was made. The parameter is case-insensitive. 160 | */ 161 | public function isMethod(string $method): bool 162 | { 163 | return strcasecmp($this->method, $method) === 0; 164 | } 165 | 166 | 167 | /** 168 | * Returns an HTTP header or `null` if it does not exist. The parameter is case-insensitive. 169 | */ 170 | public function getHeader(string $header): ?string 171 | { 172 | $header = strtolower($header); 173 | return $this->headers[$header] ?? null; 174 | } 175 | 176 | 177 | /** 178 | * Returns all HTTP headers as associative array. 179 | */ 180 | public function getHeaders(): array 181 | { 182 | return $this->headers; 183 | } 184 | 185 | 186 | #[\Deprecated('deprecated in favor of the getOrigin()')] 187 | public function getReferer(): ?UrlImmutable 188 | { 189 | trigger_error(__METHOD__ . '() is deprecated', E_USER_DEPRECATED); 190 | return isset($this->headers['referer']) 191 | ? new UrlImmutable($this->headers['referer']) 192 | : null; 193 | } 194 | 195 | 196 | /** 197 | * What origin did the user come from? It contains scheme, hostname and port. 198 | */ 199 | public function getOrigin(): ?UrlImmutable 200 | { 201 | $header = $this->headers['origin'] ?? 'null'; 202 | try { 203 | return $header === 'null' 204 | ? null 205 | : new UrlImmutable($header); 206 | } catch (Nette\InvalidArgumentException $e) { 207 | return null; 208 | } 209 | } 210 | 211 | 212 | /** 213 | * Is the request sent via secure channel (https)? 214 | */ 215 | public function isSecured(): bool 216 | { 217 | return $this->url->getScheme() === 'https'; 218 | } 219 | 220 | 221 | /** 222 | * Is the request coming from the same site and is initiated by clicking on a link? 223 | */ 224 | public function isSameSite(): bool 225 | { 226 | return isset($this->cookies[Helpers::StrictCookieName]); 227 | } 228 | 229 | 230 | /** 231 | * Is it an AJAX request? 232 | */ 233 | public function isAjax(): bool 234 | { 235 | return $this->getHeader('X-Requested-With') === 'XMLHttpRequest'; 236 | } 237 | 238 | 239 | /** 240 | * Returns the IP address of the remote client. 241 | */ 242 | public function getRemoteAddress(): ?string 243 | { 244 | return $this->remoteAddress; 245 | } 246 | 247 | 248 | /** 249 | * Returns the host of the remote client. 250 | */ 251 | public function getRemoteHost(): ?string 252 | { 253 | return $this->remoteHost; 254 | } 255 | 256 | 257 | /** 258 | * Returns raw content of HTTP request body. 259 | */ 260 | public function getRawBody(): ?string 261 | { 262 | return $this->rawBodyCallback ? ($this->rawBodyCallback)() : null; 263 | } 264 | 265 | 266 | /** 267 | * Returns decoded content of HTTP request body. 268 | */ 269 | public function getDecodedBody(): mixed 270 | { 271 | $type = $this->getHeader('Content-Type'); 272 | return match ($type) { 273 | 'application/json' => json_decode($this->getRawBody()), 274 | 'application/x-www-form-urlencoded' => $_POST, 275 | default => throw new \Exception("Unsupported content type: $type"), 276 | }; 277 | } 278 | 279 | 280 | /** 281 | * Returns basic HTTP authentication credentials. 282 | * @return array{string, string}|null 283 | */ 284 | public function getBasicCredentials(): ?array 285 | { 286 | return preg_match( 287 | '~^Basic (\S+)$~', 288 | $this->headers['authorization'] ?? '', 289 | $t, 290 | ) 291 | && ($t = base64_decode($t[1], strict: true)) 292 | && ($t = explode(':', $t, 2)) 293 | && (count($t) === 2) 294 | ? $t 295 | : null; 296 | } 297 | 298 | 299 | /** 300 | * Returns the most preferred language by browser. Uses the `Accept-Language` header. If no match is reached, it returns `null`. 301 | * @param string[] $langs supported languages 302 | */ 303 | public function detectLanguage(array $langs): ?string 304 | { 305 | $header = $this->getHeader('Accept-Language'); 306 | if (!$header) { 307 | return null; 308 | } 309 | 310 | $s = strtolower($header); // case insensitive 311 | $s = strtr($s, '_', '-'); // cs_CZ means cs-CZ 312 | rsort($langs); // first more specific 313 | preg_match_all('#(' . implode('|', $langs) . ')(?:-[^\s,;=]+)?\s*(?:;\s*q=([0-9.]+))?#', $s, $matches); 314 | 315 | if (!$matches[0]) { 316 | return null; 317 | } 318 | 319 | $max = 0; 320 | $lang = null; 321 | foreach ($matches[1] as $key => $value) { 322 | $q = $matches[2][$key] === '' ? 1.0 : (float) $matches[2][$key]; 323 | if ($q > $max) { 324 | $max = $q; 325 | $lang = $value; 326 | } 327 | } 328 | 329 | return $lang; 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /src/Http/RequestFactory.php: -------------------------------------------------------------------------------- 1 | ['#//#' => '/'], // '%20' => '' 27 | 'url' => [], // '#[.,)]$#D' => '' 28 | ]; 29 | 30 | private bool $binary = false; 31 | 32 | /** @var string[] */ 33 | private array $proxies = []; 34 | 35 | 36 | public function setBinary(bool $binary = true): static 37 | { 38 | $this->binary = $binary; 39 | return $this; 40 | } 41 | 42 | 43 | /** 44 | * @param string|string[] $proxy 45 | */ 46 | public function setProxy($proxy): static 47 | { 48 | $this->proxies = (array) $proxy; 49 | return $this; 50 | } 51 | 52 | 53 | /** 54 | * Returns new Request instance, using values from superglobals. 55 | */ 56 | public function fromGlobals(): Request 57 | { 58 | $url = new Url; 59 | $this->getServer($url); 60 | $this->getPathAndQuery($url); 61 | [$post, $cookies] = $this->getGetPostCookie($url); 62 | [$remoteAddr, $remoteHost] = $this->getClient($url); 63 | 64 | return new Request( 65 | new UrlScript($url, $this->getScriptPath($url)), 66 | $post, 67 | $this->getFiles(), 68 | $cookies, 69 | $this->getHeaders(), 70 | $this->getMethod(), 71 | $remoteAddr, 72 | $remoteHost, 73 | fn(): string => file_get_contents('php://input'), 74 | ); 75 | } 76 | 77 | 78 | private function getServer(Url $url): void 79 | { 80 | $url->setScheme(!empty($_SERVER['HTTPS']) && strcasecmp($_SERVER['HTTPS'], 'off') ? 'https' : 'http'); 81 | 82 | if ( 83 | (isset($_SERVER[$tmp = 'HTTP_HOST']) || isset($_SERVER[$tmp = 'SERVER_NAME'])) 84 | && ($pair = $this->parseHostAndPort($_SERVER[$tmp])) 85 | ) { 86 | $url->setHost($pair[0]); 87 | if (isset($pair[1])) { 88 | $url->setPort($pair[1]); 89 | } elseif ($tmp === 'SERVER_NAME' && isset($_SERVER['SERVER_PORT'])) { 90 | $url->setPort((int) $_SERVER['SERVER_PORT']); 91 | } 92 | } 93 | } 94 | 95 | 96 | private function getPathAndQuery(Url $url): void 97 | { 98 | $requestUrl = $_SERVER['REQUEST_URI'] ?? '/'; 99 | $requestUrl = preg_replace('#^\w++://[^/]++#', '', $requestUrl); 100 | $requestUrl = Strings::replace($requestUrl, $this->urlFilters['url']); 101 | 102 | $tmp = explode('?', $requestUrl, 2); 103 | $path = Url::unescape($tmp[0], '%/?#'); 104 | $path = Strings::fixEncoding(Strings::replace($path, $this->urlFilters['path'])); 105 | $url->setPath($path); 106 | $url->setQuery($tmp[1] ?? ''); 107 | } 108 | 109 | 110 | private function getScriptPath(Url $url): string 111 | { 112 | if (PHP_SAPI === 'cli-server') { 113 | return '/'; 114 | } 115 | 116 | $path = $url->getPath(); 117 | $lpath = strtolower($path); 118 | $script = strtolower($_SERVER['SCRIPT_NAME'] ?? ''); 119 | if ($lpath !== $script) { 120 | $max = min(strlen($lpath), strlen($script)); 121 | for ($i = 0; $i < $max && $lpath[$i] === $script[$i]; $i++); 122 | $path = $i 123 | ? substr($path, 0, strrpos($path, '/', $i - strlen($path) - 1) + 1) 124 | : '/'; 125 | } 126 | 127 | return $path; 128 | } 129 | 130 | 131 | private function getGetPostCookie(Url $url): array 132 | { 133 | $useFilter = (!in_array((string) ini_get('filter.default'), ['', 'unsafe_raw'], true) || ini_get('filter.default_flags')); 134 | 135 | $query = $url->getQueryParameters(); 136 | $post = $useFilter 137 | ? filter_input_array(INPUT_POST, FILTER_UNSAFE_RAW) 138 | : (empty($_POST) ? [] : $_POST); 139 | $cookies = $useFilter 140 | ? filter_input_array(INPUT_COOKIE, FILTER_UNSAFE_RAW) 141 | : (empty($_COOKIE) ? [] : $_COOKIE); 142 | 143 | // remove invalid characters 144 | $reChars = '#^[' . self::ValidChars . ']*+$#Du'; 145 | if (!$this->binary) { 146 | $list = [&$query, &$post, &$cookies]; 147 | foreach ($list as $key => &$val) { 148 | foreach ($val as $k => $v) { 149 | if (is_string($k) && (!preg_match($reChars, $k) || preg_last_error())) { 150 | unset($list[$key][$k]); 151 | 152 | } elseif (is_array($v)) { 153 | $list[$key][$k] = $v; 154 | $list[] = &$list[$key][$k]; 155 | 156 | } elseif (is_string($v)) { 157 | $list[$key][$k] = (string) preg_replace('#[^' . self::ValidChars . ']+#u', '', $v); 158 | 159 | } else { 160 | throw new Nette\InvalidStateException(sprintf('Invalid value in $_POST/$_COOKIE in key %s, expected string, %s given.', "'$k'", get_debug_type($v))); 161 | } 162 | } 163 | } 164 | 165 | unset($list, $key, $val, $k, $v); 166 | } 167 | 168 | $url->setQuery($query); 169 | return [$post, $cookies]; 170 | } 171 | 172 | 173 | private function getFiles(): array 174 | { 175 | $reChars = '#^[' . self::ValidChars . ']*+$#Du'; 176 | $files = []; 177 | $list = []; 178 | foreach ($_FILES ?? [] as $k => $v) { 179 | if ( 180 | !is_array($v) 181 | || !isset($v['name'], $v['type'], $v['size'], $v['tmp_name'], $v['error']) 182 | || (!$this->binary && is_string($k) && (!preg_match($reChars, $k) || preg_last_error())) 183 | ) { 184 | continue; 185 | } 186 | 187 | $v['@'] = &$files[$k]; 188 | $list[] = $v; 189 | } 190 | 191 | // create FileUpload objects 192 | foreach ($list as &$v) { 193 | if (!isset($v['name'])) { 194 | continue; 195 | 196 | } elseif (!is_array($v['name'])) { 197 | if (!$this->binary && (!preg_match($reChars, $v['name']) || preg_last_error())) { 198 | $v['name'] = ''; 199 | } 200 | 201 | if ($v['error'] !== UPLOAD_ERR_NO_FILE) { 202 | $v['@'] = new FileUpload($v); 203 | } 204 | 205 | continue; 206 | } 207 | 208 | foreach ($v['name'] as $k => $foo) { 209 | if (!$this->binary && is_string($k) && (!preg_match($reChars, $k) || preg_last_error())) { 210 | continue; 211 | } 212 | 213 | $list[] = [ 214 | 'name' => $v['name'][$k], 215 | 'type' => $v['type'][$k], 216 | 'size' => $v['size'][$k], 217 | 'full_path' => $v['full_path'][$k] ?? null, 218 | 'tmp_name' => $v['tmp_name'][$k], 219 | 'error' => $v['error'][$k], 220 | '@' => &$v['@'][$k], 221 | ]; 222 | } 223 | } 224 | 225 | return $files; 226 | } 227 | 228 | 229 | private function getHeaders(): array 230 | { 231 | if (function_exists('apache_request_headers')) { 232 | $headers = apache_request_headers(); 233 | } else { 234 | $headers = []; 235 | foreach ($_SERVER as $k => $v) { 236 | if (strncmp($k, 'HTTP_', 5) === 0) { 237 | $k = substr($k, 5); 238 | } elseif (strncmp($k, 'CONTENT_', 8)) { 239 | continue; 240 | } 241 | 242 | $headers[strtr($k, '_', '-')] = $v; 243 | } 244 | } 245 | 246 | if (!isset($headers['Authorization'])) { 247 | if (isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) { 248 | $headers['Authorization'] = 'Basic ' . base64_encode($_SERVER['PHP_AUTH_USER'] . ':' . $_SERVER['PHP_AUTH_PW']); 249 | } elseif (isset($_SERVER['PHP_AUTH_DIGEST'])) { 250 | $headers['Authorization'] = 'Digest ' . $_SERVER['PHP_AUTH_DIGEST']; 251 | } 252 | } 253 | 254 | return $headers; 255 | } 256 | 257 | 258 | private function getMethod(): string 259 | { 260 | $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; 261 | if ( 262 | $method === 'POST' 263 | && preg_match('#^[A-Z]+$#D', $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ?? '') 264 | ) { 265 | $method = $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']; 266 | } 267 | 268 | return $method; 269 | } 270 | 271 | 272 | private function getClient(Url $url): array 273 | { 274 | $remoteAddr = !empty($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : null; 275 | 276 | // use real client address and host if trusted proxy is used 277 | $usingTrustedProxy = $remoteAddr && Arrays::some($this->proxies, fn(string $proxy): bool => Helpers::ipMatch($remoteAddr, $proxy)); 278 | if ($usingTrustedProxy) { 279 | $remoteHost = null; 280 | $remoteAddr = empty($_SERVER['HTTP_FORWARDED']) 281 | ? $this->useNonstandardProxy($url) 282 | : $this->useForwardedProxy($url); 283 | 284 | } else { 285 | $remoteHost = !empty($_SERVER['REMOTE_HOST']) ? $_SERVER['REMOTE_HOST'] : null; 286 | } 287 | 288 | return [$remoteAddr, $remoteHost]; 289 | } 290 | 291 | 292 | private function useForwardedProxy(Url $url): ?string 293 | { 294 | $forwardParams = preg_split('/[,;]/', $_SERVER['HTTP_FORWARDED']); 295 | foreach ($forwardParams as $forwardParam) { 296 | [$key, $value] = explode('=', $forwardParam, 2) + [1 => '']; 297 | $proxyParams[strtolower(trim($key))][] = trim($value, " \t\""); 298 | } 299 | 300 | if (isset($proxyParams['for'])) { 301 | $address = $proxyParams['for'][0]; 302 | $remoteAddr = str_contains($address, '[') 303 | ? substr($address, 1, strpos($address, ']') - 1) // IPv6 304 | : explode(':', $address)[0]; // IPv4 305 | } 306 | 307 | if (isset($proxyParams['proto']) && count($proxyParams['proto']) === 1) { 308 | $url->setScheme(strcasecmp($proxyParams['proto'][0], 'https') === 0 ? 'https' : 'http'); 309 | $url->setPort($url->getScheme() === 'https' ? 443 : 80); 310 | } 311 | 312 | if ( 313 | isset($proxyParams['host']) && count($proxyParams['host']) === 1 314 | && ($pair = $this->parseHostAndPort($proxyParams['host'][0])) 315 | ) { 316 | $url->setHost($pair[0]); 317 | if (isset($pair[1])) { 318 | $url->setPort($pair[1]); 319 | } 320 | } 321 | return $remoteAddr ?? null; 322 | } 323 | 324 | 325 | private function useNonstandardProxy(Url $url): ?string 326 | { 327 | if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) { 328 | $url->setScheme(strcasecmp($_SERVER['HTTP_X_FORWARDED_PROTO'], 'https') === 0 ? 'https' : 'http'); 329 | $url->setPort($url->getScheme() === 'https' ? 443 : 80); 330 | } 331 | 332 | if (!empty($_SERVER['HTTP_X_FORWARDED_PORT'])) { 333 | $url->setPort((int) $_SERVER['HTTP_X_FORWARDED_PORT']); 334 | } 335 | 336 | if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { 337 | $xForwardedForWithoutProxies = array_filter( 338 | explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']), 339 | fn(string $ip): bool => filter_var($ip = trim($ip), FILTER_VALIDATE_IP) === false 340 | || !Arrays::some($this->proxies, fn(string $proxy): bool => Helpers::ipMatch($ip, $proxy)), 341 | ); 342 | if ($xForwardedForWithoutProxies) { 343 | $remoteAddr = trim(end($xForwardedForWithoutProxies)); 344 | $xForwardedForRealIpKey = key($xForwardedForWithoutProxies); 345 | } 346 | } 347 | 348 | if (isset($xForwardedForRealIpKey) && !empty($_SERVER['HTTP_X_FORWARDED_HOST'])) { 349 | $xForwardedHost = explode(',', $_SERVER['HTTP_X_FORWARDED_HOST']); 350 | if ( 351 | isset($xForwardedHost[$xForwardedForRealIpKey]) 352 | && ($pair = $this->parseHostAndPort(trim($xForwardedHost[$xForwardedForRealIpKey]))) 353 | ) { 354 | $url->setHost($pair[0]); 355 | if (isset($pair[1])) { 356 | $url->setPort($pair[1]); 357 | } 358 | } 359 | } 360 | 361 | return $remoteAddr ?? null; 362 | } 363 | 364 | 365 | /** @return array{string, ?int}|null */ 366 | private function parseHostAndPort(string $s): ?array 367 | { 368 | return preg_match('#^([a-z0-9_.-]+|\[[a-f0-9:]+])(:\d+)?$#Di', $s, $matches) 369 | ? [ 370 | rtrim(strtolower($matches[1]), '.'), 371 | isset($matches[2]) ? (int) substr($matches[2], 1) : null, 372 | ] 373 | : null; 374 | } 375 | 376 | 377 | #[\Deprecated('use fromGlobals()')] 378 | public function createHttpRequest(): Request 379 | { 380 | trigger_error(__METHOD__ . '() is deprecated, use fromGlobals()', E_USER_DEPRECATED); 381 | return $this->fromGlobals(); 382 | } 383 | } 384 | -------------------------------------------------------------------------------- /src/Http/Response.php: -------------------------------------------------------------------------------- 1 | code = $code; 45 | } 46 | } 47 | 48 | 49 | /** 50 | * Sets HTTP response code. 51 | * @throws Nette\InvalidArgumentException if code is invalid 52 | * @throws Nette\InvalidStateException if HTTP headers have been sent 53 | */ 54 | public function setCode(int $code, ?string $reason = null): static 55 | { 56 | if ($code < 100 || $code > 599) { 57 | throw new Nette\InvalidArgumentException("Bad HTTP response '$code'."); 58 | } 59 | 60 | self::checkHeaders(); 61 | $this->code = $code; 62 | $protocol = $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.1'; 63 | $reason ??= self::ReasonPhrases[$code] ?? 'Unknown status'; 64 | header("$protocol $code $reason"); 65 | return $this; 66 | } 67 | 68 | 69 | /** 70 | * Returns HTTP response code. 71 | */ 72 | public function getCode(): int 73 | { 74 | return $this->code; 75 | } 76 | 77 | 78 | /** 79 | * Sends an HTTP header and overwrites previously sent header of the same name. 80 | * @throws Nette\InvalidStateException if HTTP headers have been sent 81 | */ 82 | public function setHeader(string $name, ?string $value): static 83 | { 84 | self::checkHeaders(); 85 | if ($value === null) { 86 | header_remove($name); 87 | } elseif (strcasecmp($name, 'Content-Length') === 0 && ini_get('zlib.output_compression')) { 88 | // ignore, PHP bug #44164 89 | } else { 90 | header($name . ': ' . $value); 91 | } 92 | 93 | return $this; 94 | } 95 | 96 | 97 | /** 98 | * Sends an HTTP header and doesn't overwrite previously sent header of the same name. 99 | * @throws Nette\InvalidStateException if HTTP headers have been sent 100 | */ 101 | public function addHeader(string $name, string $value): static 102 | { 103 | self::checkHeaders(); 104 | header($name . ': ' . $value, replace: false); 105 | return $this; 106 | } 107 | 108 | 109 | /** 110 | * Deletes a previously sent HTTP header. 111 | * @throws Nette\InvalidStateException if HTTP headers have been sent 112 | */ 113 | public function deleteHeader(string $name): static 114 | { 115 | self::checkHeaders(); 116 | header_remove($name); 117 | return $this; 118 | } 119 | 120 | 121 | /** 122 | * Sends a Content-type HTTP header. 123 | * @throws Nette\InvalidStateException if HTTP headers have been sent 124 | */ 125 | public function setContentType(string $type, ?string $charset = null): static 126 | { 127 | $this->setHeader('Content-Type', $type . ($charset ? '; charset=' . $charset : '')); 128 | return $this; 129 | } 130 | 131 | 132 | /** 133 | * Response should be downloaded with 'Save as' dialog. 134 | * @throws Nette\InvalidStateException if HTTP headers have been sent 135 | */ 136 | public function sendAsFile(string $fileName): static 137 | { 138 | $this->setHeader( 139 | 'Content-Disposition', 140 | 'attachment; filename="' . str_replace('"', '', $fileName) . '"; ' 141 | . "filename*=utf-8''" . rawurlencode($fileName), 142 | ); 143 | return $this; 144 | } 145 | 146 | 147 | /** 148 | * Redirects to another URL. Don't forget to quit the script then. 149 | * @throws Nette\InvalidStateException if HTTP headers have been sent 150 | */ 151 | public function redirect(string $url, int $code = self::S302_Found): void 152 | { 153 | $this->setCode($code); 154 | $this->setHeader('Location', $url); 155 | if (preg_match('#^https?:|^\s*+[a-z0-9+.-]*+[^:]#i', $url)) { 156 | $escapedUrl = htmlspecialchars($url, ENT_IGNORE | ENT_QUOTES, 'UTF-8'); 157 | echo "

Redirect

\n\n

Please click here to continue.

"; 158 | } 159 | } 160 | 161 | 162 | /** 163 | * Sets the expiration of the HTTP document using the `Cache-Control` and `Expires` headers. 164 | * The parameter is either a time interval (as text) or `null`, which disables caching. 165 | * @throws Nette\InvalidStateException if HTTP headers have been sent 166 | */ 167 | public function setExpiration(?string $expire): static 168 | { 169 | $this->setHeader('Pragma', null); 170 | if (!$expire) { // no cache 171 | $this->setHeader('Cache-Control', 's-maxage=0, max-age=0, must-revalidate'); 172 | $this->setHeader('Expires', 'Mon, 23 Jan 1978 10:00:00 GMT'); 173 | return $this; 174 | } 175 | 176 | $expire = DateTime::from($expire); 177 | $this->setHeader('Cache-Control', 'max-age=' . ($expire->format('U') - time())); 178 | $this->setHeader('Expires', Helpers::formatDate($expire)); 179 | return $this; 180 | } 181 | 182 | 183 | /** 184 | * Returns whether headers have already been sent from the server to the browser, 185 | * so it is no longer possible to send headers or change the response code. 186 | */ 187 | public function isSent(): bool 188 | { 189 | return headers_sent(); 190 | } 191 | 192 | 193 | /** 194 | * Returns the sent HTTP header, or `null` if it does not exist. The parameter is case-insensitive. 195 | */ 196 | public function getHeader(string $header): ?string 197 | { 198 | $header .= ':'; 199 | $len = strlen($header); 200 | foreach (headers_list() as $item) { 201 | if (strncasecmp($item, $header, $len) === 0) { 202 | return ltrim(substr($item, $len)); 203 | } 204 | } 205 | 206 | return null; 207 | } 208 | 209 | 210 | /** 211 | * Returns all sent HTTP headers as associative array. 212 | */ 213 | public function getHeaders(): array 214 | { 215 | $headers = []; 216 | foreach (headers_list() as $header) { 217 | $a = strpos($header, ':'); 218 | $headers[substr($header, 0, $a)] = substr($header, $a + 2); 219 | } 220 | 221 | return $headers; 222 | } 223 | 224 | 225 | /** 226 | * Sends a cookie. 227 | * @throws Nette\InvalidStateException if HTTP headers have been sent 228 | */ 229 | public function setCookie( 230 | string $name, 231 | string $value, 232 | string|int|\DateTimeInterface|null $expire, 233 | ?string $path = null, 234 | ?string $domain = null, 235 | ?bool $secure = null, 236 | bool $httpOnly = true, 237 | string $sameSite = self::SameSiteLax, 238 | ): static 239 | { 240 | self::checkHeaders(); 241 | setcookie($name, $value, [ 242 | 'expires' => $expire ? (int) DateTime::from($expire)->format('U') : 0, 243 | 'path' => $path ?? ($domain ? '/' : $this->cookiePath), 244 | 'domain' => $domain ?? ($path ? '' : $this->cookieDomain), 245 | 'secure' => $secure ?? $this->cookieSecure, 246 | 'httponly' => $httpOnly, 247 | 'samesite' => $sameSite, 248 | ]); 249 | return $this; 250 | } 251 | 252 | 253 | /** 254 | * Deletes a cookie. 255 | * @throws Nette\InvalidStateException if HTTP headers have been sent 256 | */ 257 | public function deleteCookie( 258 | string $name, 259 | ?string $path = null, 260 | ?string $domain = null, 261 | ?bool $secure = null, 262 | ): void 263 | { 264 | $this->setCookie($name, '', 0, $path, $domain, $secure); 265 | } 266 | 267 | 268 | private function checkHeaders(): void 269 | { 270 | if (PHP_SAPI === 'cli') { 271 | } elseif (headers_sent($file, $line)) { 272 | throw new Nette\InvalidStateException('Cannot send header after HTTP headers have been sent' . ($file ? " (output started at $file:$line)." : '.')); 273 | 274 | } elseif ( 275 | $this->warnOnBuffer && 276 | ob_get_length() && 277 | !array_filter(ob_get_status(true), fn(array $i): bool => !$i['chunk_size']) 278 | ) { 279 | trigger_error('Possible problem: you are sending a HTTP header while already having some data in output buffer. Try Tracy\OutputDebugger or send cookies/start session earlier.'); 280 | } 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /src/Http/Session.php: -------------------------------------------------------------------------------- 1 | '', // must be disabled because PHP implementation is invalid 25 | 'use_cookies' => 1, // must be enabled to prevent Session Hijacking and Fixation 26 | 'use_only_cookies' => 1, // must be enabled to prevent Session Fixation 27 | 'use_trans_sid' => 0, // must be disabled to prevent Session Hijacking and Fixation 28 | 'use_strict_mode' => 1, // must be enabled to prevent Session Fixation 29 | 'cookie_httponly' => true, // must be enabled to prevent Session Hijacking 30 | ]; 31 | 32 | /** @var array Occurs when the session is started */ 33 | public array $onStart = []; 34 | 35 | /** @var array Occurs before the session is written to disk */ 36 | public array $onBeforeWrite = []; 37 | 38 | private bool $regenerated = false; 39 | private bool $started = false; 40 | 41 | /** default configuration */ 42 | private array $options = [ 43 | 'cookie_samesite' => IResponse::SameSiteLax, 44 | 'cookie_lifetime' => 0, // for a maximum of 3 hours or until the browser is closed 45 | 'gc_maxlifetime' => self::DefaultFileLifetime, // 3 hours 46 | ]; 47 | 48 | private readonly IRequest $request; 49 | private readonly IResponse $response; 50 | private ?\SessionHandlerInterface $handler = null; 51 | private bool $readAndClose = false; 52 | private bool $fileExists = true; 53 | private bool $autoStart = true; 54 | 55 | 56 | public function __construct(IRequest $request, IResponse $response) 57 | { 58 | $this->request = $request; 59 | $this->response = $response; 60 | $this->options['cookie_path'] = &$this->response->cookiePath; 61 | $this->options['cookie_domain'] = &$this->response->cookieDomain; 62 | $this->options['cookie_secure'] = &$this->response->cookieSecure; 63 | } 64 | 65 | 66 | /** 67 | * Starts and initializes session data. 68 | * @throws Nette\InvalidStateException 69 | */ 70 | public function start(): void 71 | { 72 | $this->doStart(); 73 | } 74 | 75 | 76 | private function doStart($mustExists = false): void 77 | { 78 | if (session_status() === PHP_SESSION_ACTIVE) { // adapt an existing session 79 | if (!$this->started) { 80 | $this->configure(self::SecurityOptions); 81 | $this->initialize(); 82 | } 83 | 84 | return; 85 | } 86 | 87 | $this->configure(self::SecurityOptions + $this->options); 88 | 89 | if (!session_id()) { // session is started for first time 90 | $id = $this->request->getCookie(session_name()); 91 | $id = is_string($id) && preg_match('#^[0-9a-zA-Z,-]{22,256}$#Di', $id) 92 | ? $id 93 | : session_create_id(); 94 | session_id($id); // causes resend of a cookie to make sure it has the right parameters 95 | } 96 | 97 | try { 98 | // session_start returns false on failure only sometimes (even in PHP >= 7.1) 99 | Nette\Utils\Callback::invokeSafe( 100 | 'session_start', 101 | [['read_and_close' => $this->readAndClose]], 102 | function (string $message) use (&$e): void { 103 | $e = new Nette\InvalidStateException($message, previous: $e); 104 | }, 105 | ); 106 | } catch (\Throwable $e) { 107 | } 108 | 109 | if ($e) { 110 | @session_write_close(); // this is needed 111 | throw $e; 112 | } 113 | 114 | if ($mustExists && $this->request->getCookie(session_name()) !== session_id()) { 115 | // PHP regenerated the ID which means that the session did not exist and cookie was invalid 116 | $this->destroy(); 117 | return; 118 | } 119 | 120 | $this->initialize(); 121 | Nette\Utils\Arrays::invoke($this->onStart, $this); 122 | } 123 | 124 | 125 | /** @internal */ 126 | public function autoStart(bool $forWrite): void 127 | { 128 | if ($this->started || (!$forWrite && !$this->exists())) { 129 | return; 130 | 131 | } elseif (!$this->autoStart) { 132 | trigger_error('Cannot auto-start session because autostarting is disabled', E_USER_WARNING); 133 | return; 134 | } 135 | 136 | $this->doStart(!$forWrite); 137 | } 138 | 139 | 140 | private function initialize(): void 141 | { 142 | $this->started = true; 143 | $this->fileExists = true; 144 | 145 | /* structure: 146 | __NF: Data, Meta, Time 147 | DATA: section->variable = data 148 | META: section->variable = Timestamp 149 | */ 150 | $nf = &$_SESSION['__NF']; 151 | 152 | if (!is_array($nf)) { 153 | $nf = []; 154 | } 155 | 156 | // regenerate empty session 157 | if (empty($nf['Time']) && !$this->readAndClose) { 158 | $nf['Time'] = time(); 159 | if ($this->request->getCookie(session_name()) === session_id()) { 160 | // ensures that the session was created with use_strict_mode (ie by Nette) 161 | $this->regenerateId(); 162 | } 163 | } 164 | 165 | // expire section variables 166 | $now = time(); 167 | foreach ($nf['META'] ?? [] as $section => $metadata) { 168 | foreach ($metadata ?? [] as $variable => $value) { 169 | if (!empty($value['T']) && $now > $value['T']) { 170 | if ($variable === '') { // expire whole section 171 | unset($nf['META'][$section], $nf['DATA'][$section]); 172 | continue 2; 173 | } 174 | 175 | unset($nf['META'][$section][$variable], $nf['DATA'][$section][$variable]); 176 | } 177 | } 178 | } 179 | } 180 | 181 | 182 | public function __destruct() 183 | { 184 | $this->clean(); 185 | } 186 | 187 | 188 | /** 189 | * Has been session started? 190 | */ 191 | public function isStarted(): bool 192 | { 193 | return $this->started && session_status() === PHP_SESSION_ACTIVE; 194 | } 195 | 196 | 197 | /** 198 | * Ends the current session and store session data. 199 | */ 200 | public function close(): void 201 | { 202 | if (session_status() === PHP_SESSION_ACTIVE) { 203 | $this->clean(); 204 | session_write_close(); 205 | $this->started = false; 206 | } 207 | } 208 | 209 | 210 | /** 211 | * Destroys all data registered to a session. 212 | */ 213 | public function destroy(): void 214 | { 215 | if (session_status() !== PHP_SESSION_ACTIVE) { 216 | throw new Nette\InvalidStateException('Session is not started.'); 217 | } 218 | 219 | session_destroy(); 220 | $_SESSION = null; 221 | $this->started = false; 222 | $this->fileExists = false; 223 | if (!$this->response->isSent()) { 224 | $params = session_get_cookie_params(); 225 | $this->response->deleteCookie(session_name(), $params['path'], $params['domain'], $params['secure']); 226 | } 227 | } 228 | 229 | 230 | /** 231 | * Does session exist for the current request? 232 | */ 233 | public function exists(): bool 234 | { 235 | return session_status() === PHP_SESSION_ACTIVE 236 | || ($this->fileExists && $this->request->getCookie($this->getName())); 237 | } 238 | 239 | 240 | /** 241 | * Regenerates the session ID. 242 | * @throws Nette\InvalidStateException 243 | */ 244 | public function regenerateId(): void 245 | { 246 | if ($this->regenerated) { 247 | return; 248 | } 249 | 250 | if (session_status() === PHP_SESSION_ACTIVE) { 251 | if (headers_sent($file, $line)) { 252 | throw new Nette\InvalidStateException('Cannot regenerate session ID after HTTP headers have been sent' . ($file ? " (output started at $file:$line)." : '.')); 253 | } 254 | 255 | session_regenerate_id(true); 256 | } else { 257 | session_id(session_create_id()); 258 | } 259 | 260 | $this->regenerated = true; 261 | } 262 | 263 | 264 | /** 265 | * Returns the current session ID. Don't make dependencies, can be changed for each request. 266 | */ 267 | public function getId(): string 268 | { 269 | return session_id(); 270 | } 271 | 272 | 273 | /** 274 | * Sets the session name to a specified one. 275 | */ 276 | public function setName(string $name): static 277 | { 278 | if (!preg_match('#[^0-9.][^.]*$#DA', $name)) { 279 | throw new Nette\InvalidArgumentException('Session name cannot contain dot.'); 280 | } 281 | 282 | session_name($name); 283 | return $this->setOptions([ 284 | 'name' => $name, 285 | ]); 286 | } 287 | 288 | 289 | /** 290 | * Gets the session name. 291 | */ 292 | public function getName(): string 293 | { 294 | return $this->options['name'] ?? session_name(); 295 | } 296 | 297 | 298 | /********************* sections management ****************d*g**/ 299 | 300 | 301 | /** 302 | * Returns specified session section. 303 | * @template T of SessionSection 304 | * @param class-string $class 305 | * @return T 306 | */ 307 | public function getSection(string $section, string $class = SessionSection::class): SessionSection 308 | { 309 | return new $class($this, $section); 310 | } 311 | 312 | 313 | /** 314 | * Checks if a session section exist and is not empty. 315 | */ 316 | public function hasSection(string $section): bool 317 | { 318 | if ($this->exists() && !$this->started) { 319 | $this->autoStart(false); 320 | } 321 | 322 | return !empty($_SESSION['__NF']['DATA'][$section]); 323 | } 324 | 325 | 326 | /** @return string[] */ 327 | public function getSectionNames(): array 328 | { 329 | if ($this->exists() && !$this->started) { 330 | $this->autoStart(false); 331 | } 332 | 333 | return array_keys($_SESSION['__NF']['DATA'] ?? []); 334 | } 335 | 336 | 337 | /** 338 | * Cleans and minimizes meta structures. 339 | */ 340 | private function clean(): void 341 | { 342 | if (!$this->isStarted()) { 343 | return; 344 | } 345 | 346 | Nette\Utils\Arrays::invoke($this->onBeforeWrite, $this); 347 | 348 | $nf = &$_SESSION['__NF']; 349 | foreach ($nf['DATA'] ?? [] as $name => $data) { 350 | foreach ($data ?? [] as $k => $v) { 351 | if ($v === null) { 352 | unset($nf['DATA'][$name][$k], $nf['META'][$name][$k]); 353 | } 354 | } 355 | 356 | if (empty($nf['DATA'][$name])) { 357 | unset($nf['DATA'][$name], $nf['META'][$name]); 358 | } 359 | } 360 | 361 | foreach ($nf['META'] ?? [] as $name => $data) { 362 | if (empty($nf['META'][$name])) { 363 | unset($nf['META'][$name]); 364 | } 365 | } 366 | } 367 | 368 | 369 | /********************* configuration ****************d*g**/ 370 | 371 | 372 | /** 373 | * Sets session options. 374 | * @throws Nette\NotSupportedException 375 | * @throws Nette\InvalidStateException 376 | */ 377 | public function setOptions(array $options): static 378 | { 379 | $normalized = []; 380 | $allowed = ini_get_all('session', details: false) + ['session.read_and_close' => 1]; 381 | 382 | foreach ($options as $key => $value) { 383 | if (!strncmp($key, 'session.', 8)) { // back compatibility 384 | $key = substr($key, 8); 385 | } 386 | 387 | $normKey = strtolower(preg_replace('#(.)(?=[A-Z])#', '$1_', $key)); // camelCase -> snake_case 388 | 389 | if (!isset($allowed["session.$normKey"])) { 390 | $hint = substr((string) Nette\Utils\Helpers::getSuggestion(array_keys($allowed), "session.$normKey"), 8); 391 | if ($key !== $normKey) { 392 | $hint = preg_replace_callback('#_(.)#', fn($m) => strtoupper($m[1]), $hint); // snake_case -> camelCase 393 | } 394 | 395 | throw new Nette\InvalidStateException("Invalid session configuration option '$key'" . ($hint ? ", did you mean '$hint'?" : '.')); 396 | } 397 | 398 | $normalized[$normKey] = $value; 399 | } 400 | 401 | if (isset($normalized['read_and_close'])) { 402 | if (session_status() === PHP_SESSION_ACTIVE) { 403 | throw new Nette\InvalidStateException('Cannot configure "read_and_close" for already started session.'); 404 | } 405 | 406 | $this->readAndClose = (bool) $normalized['read_and_close']; 407 | unset($normalized['read_and_close']); 408 | } 409 | 410 | $this->autoStart = $normalized['auto_start'] ?? true; 411 | unset($normalized['auto_start']); 412 | 413 | if (session_status() === PHP_SESSION_ACTIVE) { 414 | $this->configure($normalized); 415 | } 416 | 417 | $this->options = $normalized + $this->options; 418 | return $this; 419 | } 420 | 421 | 422 | /** 423 | * Returns all session options. 424 | */ 425 | public function getOptions(): array 426 | { 427 | return $this->options; 428 | } 429 | 430 | 431 | /** 432 | * Configures session environment. 433 | */ 434 | private function configure(array $config): void 435 | { 436 | $special = ['cache_expire' => 1, 'cache_limiter' => 1, 'save_path' => 1, 'name' => 1]; 437 | $cookie = $origCookie = session_get_cookie_params(); 438 | 439 | foreach ($config as $key => $value) { 440 | if ($value === null || ini_get("session.$key") == $value) { // intentionally == 441 | continue; 442 | 443 | } elseif (strncmp($key, 'cookie_', 7) === 0) { 444 | $cookie[substr($key, 7)] = $value; 445 | 446 | } else { 447 | if (session_status() === PHP_SESSION_ACTIVE) { 448 | throw new Nette\InvalidStateException("Unable to set 'session.$key' to value '$value' when session has been started" . ($this->started ? '.' : ' by session.auto_start or session_start().')); 449 | } 450 | 451 | if (isset($special[$key])) { 452 | ("session_$key")($value); 453 | 454 | } elseif (function_exists('ini_set')) { 455 | ini_set("session.$key", (string) $value); 456 | 457 | } else { 458 | throw new Nette\NotSupportedException("Unable to set 'session.$key' to '$value' because function ini_set() is disabled."); 459 | } 460 | } 461 | } 462 | 463 | if ($cookie !== $origCookie) { 464 | @session_set_cookie_params($cookie); // @ may trigger warning when session is active since PHP 7.2 465 | 466 | if (session_status() === PHP_SESSION_ACTIVE) { 467 | $this->sendCookie(); 468 | } 469 | } 470 | 471 | if ($this->handler) { 472 | session_set_save_handler($this->handler); 473 | } 474 | } 475 | 476 | 477 | /** 478 | * Sets the amount of time (like '20 minutes') allowed between requests before the session will be terminated, 479 | * null means "for a maximum of 3 hours or until the browser is closed". 480 | */ 481 | public function setExpiration(?string $expire): static 482 | { 483 | if ($expire === null) { 484 | return $this->setOptions([ 485 | 'gc_maxlifetime' => self::DefaultFileLifetime, 486 | 'cookie_lifetime' => 0, 487 | ]); 488 | 489 | } else { 490 | $expire = Nette\Utils\DateTime::from($expire)->format('U') - time(); 491 | return $this->setOptions([ 492 | 'gc_maxlifetime' => $expire, 493 | 'cookie_lifetime' => $expire, 494 | ]); 495 | } 496 | } 497 | 498 | 499 | /** 500 | * Sets the session cookie parameters. 501 | */ 502 | public function setCookieParameters( 503 | string $path, 504 | ?string $domain = null, 505 | ?bool $secure = null, 506 | ?string $sameSite = null, 507 | ): static 508 | { 509 | return $this->setOptions([ 510 | 'cookie_path' => $path, 511 | 'cookie_domain' => $domain, 512 | 'cookie_secure' => $secure, 513 | 'cookie_samesite' => $sameSite, 514 | ]); 515 | } 516 | 517 | 518 | /** 519 | * Sets path of the directory used to save session data. 520 | */ 521 | public function setSavePath(string $path): static 522 | { 523 | return $this->setOptions([ 524 | 'save_path' => $path, 525 | ]); 526 | } 527 | 528 | 529 | /** 530 | * Sets user session handler. 531 | */ 532 | public function setHandler(\SessionHandlerInterface $handler): static 533 | { 534 | if ($this->started) { 535 | throw new Nette\InvalidStateException('Unable to set handler when session has been started.'); 536 | } 537 | 538 | $this->handler = $handler; 539 | return $this; 540 | } 541 | 542 | 543 | /** 544 | * Sends the session cookies. 545 | */ 546 | private function sendCookie(): void 547 | { 548 | $cookie = session_get_cookie_params(); 549 | $this->response->setCookie( 550 | session_name(), 551 | session_id(), 552 | $cookie['lifetime'] ? $cookie['lifetime'] + time() : 0, 553 | $cookie['path'], 554 | $cookie['domain'], 555 | $cookie['secure'], 556 | $cookie['httponly'], 557 | $cookie['samesite'] ?? null, 558 | ); 559 | } 560 | } 561 | -------------------------------------------------------------------------------- /src/Http/SessionSection.php: -------------------------------------------------------------------------------- 1 | session->autoStart(false); 36 | return new \ArrayIterator($this->getData() ?? []); 37 | } 38 | 39 | 40 | /** 41 | * Sets a variable in this session section. 42 | */ 43 | public function set(string $name, mixed $value, ?string $expire = null): void 44 | { 45 | if ($value === null) { 46 | $this->remove($name); 47 | } else { 48 | $this->session->autoStart(true); 49 | $this->getData()[$name] = $value; 50 | $this->setExpiration($expire, $name); 51 | } 52 | } 53 | 54 | 55 | /** 56 | * Gets a variable from this session section. 57 | */ 58 | public function get(string $name): mixed 59 | { 60 | if (func_num_args() > 1) { 61 | throw new \ArgumentCountError(__METHOD__ . '() expects 1 arguments, given more.'); 62 | } 63 | 64 | $this->session->autoStart(false); 65 | return $this->getData()[$name] ?? null; 66 | } 67 | 68 | 69 | /** 70 | * Removes a variable or whole section. 71 | * @param string|string[]|null $name 72 | */ 73 | public function remove(string|array|null $name = null): void 74 | { 75 | $this->session->autoStart(false); 76 | if (func_num_args() > 1) { 77 | throw new \ArgumentCountError(__METHOD__ . '() expects at most 1 arguments, given more.'); 78 | 79 | } elseif (func_num_args()) { 80 | $data = &$this->getData(); 81 | $meta = &$this->getMeta(); 82 | foreach ((array) $name as $name) { 83 | unset($data[$name], $meta[$name]); 84 | } 85 | } else { 86 | unset($_SESSION['__NF']['DATA'][$this->name], $_SESSION['__NF']['META'][$this->name]); 87 | } 88 | } 89 | 90 | 91 | #[\Deprecated('use set() instead')] 92 | public function __set(string $name, $value): void 93 | { 94 | trigger_error("Writing to \$session->$name is deprecated, use \$session->set('$name', \$value) instead", E_USER_DEPRECATED); 95 | $this->session->autoStart(true); 96 | $this->getData()[$name] = $value; 97 | } 98 | 99 | 100 | #[\Deprecated('use get() instead')] 101 | public function &__get(string $name): mixed 102 | { 103 | trigger_error("Reading from \$session->$name is deprecated, use \$session->get('$name') instead", E_USER_DEPRECATED); 104 | $this->session->autoStart(true); 105 | $data = &$this->getData(); 106 | return $data[$name]; 107 | } 108 | 109 | 110 | #[\Deprecated('use get() instead')] 111 | public function __isset(string $name): bool 112 | { 113 | trigger_error("Using \$session->$name is deprecated, use \$session->get('$name') instead", E_USER_DEPRECATED); 114 | $this->session->autoStart(false); 115 | return isset($this->getData()[$name]); 116 | } 117 | 118 | 119 | #[\Deprecated('use remove() instead')] 120 | public function __unset(string $name): void 121 | { 122 | trigger_error("Unset(\$session->$name) is deprecated, use \$session->remove('$name') instead", E_USER_DEPRECATED); 123 | $this->remove($name); 124 | } 125 | 126 | 127 | #[\Deprecated('use set() instead')] 128 | public function offsetSet($name, $value): void 129 | { 130 | trigger_error("Writing to \$session['$name'] is deprecated, use \$session->set('$name', \$value) instead", E_USER_DEPRECATED); 131 | $this->__set($name, $value); 132 | } 133 | 134 | 135 | #[\Deprecated('use get() instead')] 136 | public function offsetGet($name): mixed 137 | { 138 | trigger_error("Reading from \$session['$name'] is deprecated, use \$session->get('$name') instead", E_USER_DEPRECATED); 139 | return $this->get($name); 140 | } 141 | 142 | 143 | #[\Deprecated('use get() instead')] 144 | public function offsetExists($name): bool 145 | { 146 | trigger_error("Using \$session['$name'] is deprecated, use \$session->get('$name') instead", E_USER_DEPRECATED); 147 | return $this->__isset($name); 148 | } 149 | 150 | 151 | #[\Deprecated('use remove() instead')] 152 | public function offsetUnset($name): void 153 | { 154 | trigger_error("Unset(\$session['$name']) is deprecated, use \$session->remove('$name') instead", E_USER_DEPRECATED); 155 | $this->remove($name); 156 | } 157 | 158 | 159 | /** 160 | * Sets the expiration of the section or specific variables. 161 | * @param string|string[]|null $variables list of variables / single variable to expire 162 | */ 163 | public function setExpiration(?string $expire, string|array|null $variables = null): static 164 | { 165 | $this->session->autoStart((bool) $expire); 166 | $meta = &$this->getMeta(); 167 | if ($expire) { 168 | $expire = Nette\Utils\DateTime::from($expire)->format('U'); 169 | $max = (int) ini_get('session.gc_maxlifetime'); 170 | if ( 171 | $max !== 0 // 0 - unlimited in memcache handler 172 | && ($expire - time() > $max + 3) // 3 - bulgarian constant 173 | ) { 174 | trigger_error("The expiration time is greater than the session expiration $max seconds"); 175 | } 176 | } 177 | 178 | foreach (is_array($variables) ? $variables : [$variables] as $variable) { 179 | $meta[$variable]['T'] = $expire ?: null; 180 | } 181 | 182 | return $this; 183 | } 184 | 185 | 186 | /** 187 | * Removes the expiration from the section or specific variables. 188 | * @param string|string[]|null $variables list of variables / single variable to expire 189 | */ 190 | public function removeExpiration(string|array|null $variables = null): void 191 | { 192 | $this->setExpiration(null, $variables); 193 | } 194 | 195 | 196 | private function &getData() 197 | { 198 | return $_SESSION['__NF']['DATA'][$this->name]; 199 | } 200 | 201 | 202 | private function &getMeta() 203 | { 204 | return $_SESSION['__NF']['META'][$this->name]; 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/Http/Url.php: -------------------------------------------------------------------------------- 1 | 19 | * scheme user password host port path query fragment 20 | * | | | | | | | | 21 | * /--\ /--\ /------\ /-------\ /--\/------------\ /--------\ /------\ 22 | * http://john:x0y17575@nette.org:8042/en/manual.php?name=param#fragment <-- absoluteUrl 23 | * \______\__________________________/ 24 | * | | 25 | * hostUrl authority 26 | * 27 | * 28 | * @property string $scheme 29 | * @property string $user 30 | * @property string $password 31 | * @property string $host 32 | * @property int $port 33 | * @property string $path 34 | * @property string $query 35 | * @property string $fragment 36 | * @property-read string $absoluteUrl 37 | * @property-read string $authority 38 | * @property-read string $hostUrl 39 | * @property-read string $basePath 40 | * @property-read string $baseUrl 41 | * @property-read string $relativeUrl 42 | * @property-read array $queryParameters 43 | */ 44 | class Url implements \JsonSerializable 45 | { 46 | use Nette\SmartObject; 47 | 48 | public static array $defaultPorts = [ 49 | 'http' => 80, 50 | 'https' => 443, 51 | 'ftp' => 21, 52 | ]; 53 | 54 | private string $scheme = ''; 55 | private string $user = ''; 56 | private string $password = ''; 57 | private string $host = ''; 58 | private ?int $port = null; 59 | private string $path = ''; 60 | private array $query = []; 61 | private string $fragment = ''; 62 | 63 | 64 | /** 65 | * @throws Nette\InvalidArgumentException if URL is malformed 66 | */ 67 | public function __construct(string|self|UrlImmutable|null $url = null) 68 | { 69 | if (is_string($url)) { 70 | $p = @parse_url($url); // @ - is escalated to exception 71 | if ($p === false) { 72 | throw new Nette\InvalidArgumentException("Malformed or unsupported URI '$url'."); 73 | } 74 | 75 | $this->scheme = $p['scheme'] ?? ''; 76 | $this->port = $p['port'] ?? null; 77 | $this->host = rawurldecode($p['host'] ?? ''); 78 | $this->user = rawurldecode($p['user'] ?? ''); 79 | $this->password = rawurldecode($p['pass'] ?? ''); 80 | $this->setPath($p['path'] ?? ''); 81 | $this->setQuery($p['query'] ?? []); 82 | $this->fragment = rawurldecode($p['fragment'] ?? ''); 83 | 84 | } elseif ($url instanceof UrlImmutable || $url instanceof self) { 85 | [$this->scheme, $this->user, $this->password, $this->host, $this->port, $this->path, $this->query, $this->fragment] = $url->export(); 86 | } 87 | } 88 | 89 | 90 | public function setScheme(string $scheme): static 91 | { 92 | $this->scheme = $scheme; 93 | return $this; 94 | } 95 | 96 | 97 | public function getScheme(): string 98 | { 99 | return $this->scheme; 100 | } 101 | 102 | 103 | #[\Deprecated] 104 | public function setUser(string $user): static 105 | { 106 | trigger_error(__METHOD__ . '() is deprecated', E_USER_DEPRECATED); 107 | $this->user = $user; 108 | return $this; 109 | } 110 | 111 | 112 | #[\Deprecated] 113 | public function getUser(): string 114 | { 115 | trigger_error(__METHOD__ . '() is deprecated', E_USER_DEPRECATED); 116 | return $this->user; 117 | } 118 | 119 | 120 | #[\Deprecated] 121 | public function setPassword(string $password): static 122 | { 123 | trigger_error(__METHOD__ . '() is deprecated', E_USER_DEPRECATED); 124 | $this->password = $password; 125 | return $this; 126 | } 127 | 128 | 129 | #[\Deprecated] 130 | public function getPassword(): string 131 | { 132 | trigger_error(__METHOD__ . '() is deprecated', E_USER_DEPRECATED); 133 | return $this->password; 134 | } 135 | 136 | 137 | public function setHost(string $host): static 138 | { 139 | $this->host = $host; 140 | $this->setPath($this->path); 141 | return $this; 142 | } 143 | 144 | 145 | public function getHost(): string 146 | { 147 | return $this->host; 148 | } 149 | 150 | 151 | /** 152 | * Returns the part of domain. 153 | */ 154 | public function getDomain(int $level = 2): string 155 | { 156 | $parts = ip2long($this->host) 157 | ? [$this->host] 158 | : explode('.', $this->host); 159 | $parts = $level >= 0 160 | ? array_slice($parts, -$level) 161 | : array_slice($parts, 0, $level); 162 | return implode('.', $parts); 163 | } 164 | 165 | 166 | public function setPort(int $port): static 167 | { 168 | $this->port = $port; 169 | return $this; 170 | } 171 | 172 | 173 | public function getPort(): ?int 174 | { 175 | return $this->port ?: $this->getDefaultPort(); 176 | } 177 | 178 | 179 | public function getDefaultPort(): ?int 180 | { 181 | return self::$defaultPorts[$this->scheme] ?? null; 182 | } 183 | 184 | 185 | public function setPath(string $path): static 186 | { 187 | $this->path = $path; 188 | if ($this->host && !str_starts_with($this->path, '/')) { 189 | $this->path = '/' . $this->path; 190 | } 191 | 192 | return $this; 193 | } 194 | 195 | 196 | public function getPath(): string 197 | { 198 | return $this->path; 199 | } 200 | 201 | 202 | public function setQuery(string|array $query): static 203 | { 204 | $this->query = is_array($query) ? $query : self::parseQuery($query); 205 | return $this; 206 | } 207 | 208 | 209 | public function appendQuery(string|array $query): static 210 | { 211 | $this->query = is_array($query) 212 | ? $query + $this->query 213 | : self::parseQuery($this->getQuery() . '&' . $query); 214 | return $this; 215 | } 216 | 217 | 218 | public function getQuery(): string 219 | { 220 | return http_build_query($this->query, '', '&', PHP_QUERY_RFC3986); 221 | } 222 | 223 | 224 | public function getQueryParameters(): array 225 | { 226 | return $this->query; 227 | } 228 | 229 | 230 | public function getQueryParameter(string $name): mixed 231 | { 232 | return $this->query[$name] ?? null; 233 | } 234 | 235 | 236 | public function setQueryParameter(string $name, mixed $value): static 237 | { 238 | $this->query[$name] = $value; 239 | return $this; 240 | } 241 | 242 | 243 | public function setFragment(string $fragment): static 244 | { 245 | $this->fragment = $fragment; 246 | return $this; 247 | } 248 | 249 | 250 | public function getFragment(): string 251 | { 252 | return $this->fragment; 253 | } 254 | 255 | 256 | public function getAbsoluteUrl(): string 257 | { 258 | return $this->getHostUrl() . $this->path 259 | . (($tmp = $this->getQuery()) ? '?' . $tmp : '') 260 | . ($this->fragment === '' ? '' : '#' . $this->fragment); 261 | } 262 | 263 | 264 | /** 265 | * Returns the [user[:pass]@]host[:port] part of URI. 266 | */ 267 | public function getAuthority(): string 268 | { 269 | return $this->host === '' 270 | ? '' 271 | : ($this->user !== '' 272 | ? rawurlencode($this->user) . ($this->password === '' ? '' : ':' . rawurlencode($this->password)) . '@' 273 | : '') 274 | . $this->host 275 | . ($this->port && $this->port !== $this->getDefaultPort() 276 | ? ':' . $this->port 277 | : ''); 278 | } 279 | 280 | 281 | /** 282 | * Returns the scheme and authority part of URI. 283 | */ 284 | public function getHostUrl(): string 285 | { 286 | return ($this->scheme ? $this->scheme . ':' : '') 287 | . (($authority = $this->getAuthority()) !== '' ? '//' . $authority : ''); 288 | } 289 | 290 | 291 | #[\Deprecated('use UrlScript::getBasePath() instead')] 292 | public function getBasePath(): string 293 | { 294 | trigger_error(__METHOD__ . '() is deprecated, use UrlScript object', E_USER_DEPRECATED); 295 | $pos = strrpos($this->path, '/'); 296 | return $pos === false ? '' : substr($this->path, 0, $pos + 1); 297 | } 298 | 299 | 300 | #[\Deprecated('use UrlScript::getBaseUrl() instead')] 301 | public function getBaseUrl(): string 302 | { 303 | trigger_error(__METHOD__ . '() is deprecated, use UrlScript object', E_USER_DEPRECATED); 304 | return $this->getHostUrl() . $this->getBasePath(); 305 | } 306 | 307 | 308 | #[\Deprecated('use UrlScript::getRelativeUrl() instead')] 309 | public function getRelativeUrl(): string 310 | { 311 | trigger_error(__METHOD__ . '() is deprecated, use UrlScript object', E_USER_DEPRECATED); 312 | return substr($this->getAbsoluteUrl(), strlen($this->getBaseUrl())); 313 | } 314 | 315 | 316 | /** 317 | * URL comparison. 318 | */ 319 | public function isEqual(string|self|UrlImmutable $url): bool 320 | { 321 | $url = new self($url); 322 | $query = $url->query; 323 | ksort($query); 324 | $query2 = $this->query; 325 | ksort($query2); 326 | $host = rtrim($url->host, '.'); 327 | $host2 = rtrim($this->host, '.'); 328 | return $url->scheme === $this->scheme 329 | && (!strcasecmp($host, $host2) 330 | || self::idnHostToUnicode($host) === self::idnHostToUnicode($host2)) 331 | && $url->getPort() === $this->getPort() 332 | && $url->user === $this->user 333 | && $url->password === $this->password 334 | && self::unescape($url->path, '%/') === self::unescape($this->path, '%/') 335 | && $query === $query2 336 | && $url->fragment === $this->fragment; 337 | } 338 | 339 | 340 | /** 341 | * Transforms URL to canonical form. 342 | */ 343 | public function canonicalize(): static 344 | { 345 | $this->path = preg_replace_callback( 346 | '#[^!$&\'()*+,/:;=@%"]+#', 347 | fn(array $m): string => rawurlencode($m[0]), 348 | self::unescape($this->path, '%/'), 349 | ); 350 | $this->host = rtrim($this->host, '.'); 351 | $this->host = self::idnHostToUnicode(strtolower($this->host)); 352 | return $this; 353 | } 354 | 355 | 356 | public function __toString(): string 357 | { 358 | return $this->getAbsoluteUrl(); 359 | } 360 | 361 | 362 | public function jsonSerialize(): string 363 | { 364 | return $this->getAbsoluteUrl(); 365 | } 366 | 367 | 368 | /** @internal */ 369 | final public function export(): array 370 | { 371 | return [$this->scheme, $this->user, $this->password, $this->host, $this->port, $this->path, $this->query, $this->fragment]; 372 | } 373 | 374 | 375 | /** 376 | * Converts IDN ASCII host to UTF-8. 377 | */ 378 | private static function idnHostToUnicode(string $host): string 379 | { 380 | if (!str_contains($host, '--')) { // host does not contain IDN 381 | return $host; 382 | } 383 | 384 | if (function_exists('idn_to_utf8') && defined('INTL_IDNA_VARIANT_UTS46')) { 385 | return idn_to_utf8($host, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46) ?: $host; 386 | } 387 | 388 | trigger_error('PHP extension intl is not loaded or is too old', E_USER_WARNING); 389 | } 390 | 391 | 392 | /** 393 | * Similar to rawurldecode, but preserves reserved chars encoded. 394 | */ 395 | public static function unescape(string $s, string $reserved = '%;/?:@&=+$,'): string 396 | { 397 | // reserved (@see RFC 2396) = ";" | "/" | "?" | ":" | "@" | "&" | "=" | "+" | "$" | "," 398 | // within a path segment, the characters "/", ";", "=", "?" are reserved 399 | // within a query component, the characters ";", "/", "?", ":", "@", "&", "=", "+", ",", "$" are reserved. 400 | if ($reserved !== '') { 401 | $s = preg_replace_callback( 402 | '#%(' . substr(chunk_split(bin2hex($reserved), 2, '|'), 0, -1) . ')#i', 403 | fn(array $m): string => '%25' . strtoupper($m[1]), 404 | $s, 405 | ); 406 | } 407 | 408 | return rawurldecode($s); 409 | } 410 | 411 | 412 | /** 413 | * Parses query string. Is affected by directive arg_separator.input. 414 | */ 415 | public static function parseQuery(string $s): array 416 | { 417 | $s = str_replace(['%5B', '%5b'], '[', $s); 418 | $sep = preg_quote(ini_get('arg_separator.input')); 419 | $s = preg_replace("#([$sep])([^[$sep=]+)([^$sep]*)#", '&0[$2]$3', '&' . $s); 420 | parse_str($s, $res); 421 | return $res[0] ?? []; 422 | } 423 | 424 | 425 | /** 426 | * Determines if URL is absolute, ie if it starts with a scheme followed by colon. 427 | */ 428 | public static function isAbsolute(string $url): bool 429 | { 430 | return (bool) preg_match('#^[a-z][a-z0-9+.-]*:#i', $url); 431 | } 432 | 433 | 434 | /** 435 | * Normalizes a path by handling and removing relative path references like '.', '..' and directory traversal. 436 | */ 437 | public static function removeDotSegments(string $path): string 438 | { 439 | $prefix = $segment = ''; 440 | if (str_starts_with($path, '/')) { 441 | $prefix = '/'; 442 | $path = substr($path, 1); 443 | } 444 | $segments = explode('/', $path); 445 | $res = []; 446 | foreach ($segments as $segment) { 447 | if ($segment === '..') { 448 | array_pop($res); 449 | } elseif ($segment !== '.') { 450 | $res[] = $segment; 451 | } 452 | } 453 | 454 | if ($segment === '.' || $segment === '..') { 455 | $res[] = ''; 456 | } 457 | return $prefix . implode('/', $res); 458 | } 459 | } 460 | -------------------------------------------------------------------------------- /src/Http/UrlImmutable.php: -------------------------------------------------------------------------------- 1 | 19 | * scheme user password host port path query fragment 20 | * | | | | | | | | 21 | * /--\ /--\ /------\ /-------\ /--\/------------\ /--------\ /------\ 22 | * http://john:x0y17575@nette.org:8042/en/manual.php?name=param#fragment <-- absoluteUrl 23 | * \______\__________________________/ 24 | * | | 25 | * hostUrl authority 26 | * 27 | * 28 | * @property-read string $scheme 29 | * @property-read string $user 30 | * @property-read string $password 31 | * @property-read string $host 32 | * @property-read int $port 33 | * @property-read string $path 34 | * @property-read string $query 35 | * @property-read string $fragment 36 | * @property-read string $absoluteUrl 37 | * @property-read string $authority 38 | * @property-read string $hostUrl 39 | * @property-read array $queryParameters 40 | */ 41 | class UrlImmutable implements \JsonSerializable 42 | { 43 | use Nette\SmartObject; 44 | 45 | private string $scheme = ''; 46 | private string $user = ''; 47 | private string $password = ''; 48 | private string $host = ''; 49 | private ?int $port = null; 50 | private string $path = ''; 51 | private array $query = []; 52 | private string $fragment = ''; 53 | private ?string $authority = null; 54 | 55 | 56 | /** 57 | * @throws Nette\InvalidArgumentException if URL is malformed 58 | */ 59 | public function __construct(string|self|Url $url) 60 | { 61 | $url = is_string($url) ? new Url($url) : $url; 62 | [$this->scheme, $this->user, $this->password, $this->host, $this->port, $this->path, $this->query, $this->fragment] = $url->export(); 63 | } 64 | 65 | 66 | public function withScheme(string $scheme): static 67 | { 68 | $dolly = clone $this; 69 | $dolly->scheme = $scheme; 70 | $dolly->authority = null; 71 | return $dolly; 72 | } 73 | 74 | 75 | public function getScheme(): string 76 | { 77 | return $this->scheme; 78 | } 79 | 80 | 81 | #[\Deprecated] 82 | public function withUser(string $user): static 83 | { 84 | trigger_error(__METHOD__ . '() is deprecated', E_USER_DEPRECATED); 85 | $dolly = clone $this; 86 | $dolly->user = $user; 87 | $dolly->authority = null; 88 | return $dolly; 89 | } 90 | 91 | 92 | #[\Deprecated] 93 | public function getUser(): string 94 | { 95 | trigger_error(__METHOD__ . '() is deprecated', E_USER_DEPRECATED); 96 | return $this->user; 97 | } 98 | 99 | 100 | #[\Deprecated] 101 | public function withPassword(string $password): static 102 | { 103 | trigger_error(__METHOD__ . '() is deprecated', E_USER_DEPRECATED); 104 | $dolly = clone $this; 105 | $dolly->password = $password; 106 | $dolly->authority = null; 107 | return $dolly; 108 | } 109 | 110 | 111 | #[\Deprecated] 112 | public function getPassword(): string 113 | { 114 | trigger_error(__METHOD__ . '() is deprecated', E_USER_DEPRECATED); 115 | return $this->password; 116 | } 117 | 118 | 119 | #[\Deprecated] 120 | public function withoutUserInfo(): static 121 | { 122 | trigger_error(__METHOD__ . '() is deprecated', E_USER_DEPRECATED); 123 | $dolly = clone $this; 124 | $dolly->user = $dolly->password = ''; 125 | $dolly->authority = null; 126 | return $dolly; 127 | } 128 | 129 | 130 | public function withHost(string $host): static 131 | { 132 | $dolly = clone $this; 133 | $dolly->host = $host; 134 | $dolly->authority = null; 135 | return $dolly->setPath($dolly->path); 136 | } 137 | 138 | 139 | public function getHost(): string 140 | { 141 | return $this->host; 142 | } 143 | 144 | 145 | public function getDomain(int $level = 2): string 146 | { 147 | $parts = ip2long($this->host) 148 | ? [$this->host] 149 | : explode('.', $this->host); 150 | $parts = $level >= 0 151 | ? array_slice($parts, -$level) 152 | : array_slice($parts, 0, $level); 153 | return implode('.', $parts); 154 | } 155 | 156 | 157 | public function withPort(int $port): static 158 | { 159 | $dolly = clone $this; 160 | $dolly->port = $port; 161 | $dolly->authority = null; 162 | return $dolly; 163 | } 164 | 165 | 166 | public function getPort(): ?int 167 | { 168 | return $this->port ?: $this->getDefaultPort(); 169 | } 170 | 171 | 172 | public function getDefaultPort(): ?int 173 | { 174 | return Url::$defaultPorts[$this->scheme] ?? null; 175 | } 176 | 177 | 178 | public function withPath(string $path): static 179 | { 180 | return (clone $this)->setPath($path); 181 | } 182 | 183 | 184 | private function setPath(string $path): static 185 | { 186 | $this->path = $this->host && !str_starts_with($path, '/') ? '/' . $path : $path; 187 | return $this; 188 | } 189 | 190 | 191 | public function getPath(): string 192 | { 193 | return $this->path; 194 | } 195 | 196 | 197 | public function withQuery(string|array $query): static 198 | { 199 | $dolly = clone $this; 200 | $dolly->query = is_array($query) ? $query : Url::parseQuery($query); 201 | return $dolly; 202 | } 203 | 204 | 205 | public function getQuery(): string 206 | { 207 | return http_build_query($this->query, '', '&', PHP_QUERY_RFC3986); 208 | } 209 | 210 | 211 | public function withQueryParameter(string $name, mixed $value): static 212 | { 213 | $dolly = clone $this; 214 | $dolly->query[$name] = $value; 215 | return $dolly; 216 | } 217 | 218 | 219 | public function getQueryParameters(): array 220 | { 221 | return $this->query; 222 | } 223 | 224 | 225 | public function getQueryParameter(string $name): array|string|null 226 | { 227 | return $this->query[$name] ?? null; 228 | } 229 | 230 | 231 | public function withFragment(string $fragment): static 232 | { 233 | $dolly = clone $this; 234 | $dolly->fragment = $fragment; 235 | return $dolly; 236 | } 237 | 238 | 239 | public function getFragment(): string 240 | { 241 | return $this->fragment; 242 | } 243 | 244 | 245 | /** 246 | * Returns the entire URI including query string and fragment. 247 | */ 248 | public function getAbsoluteUrl(): string 249 | { 250 | return $this->getHostUrl() . $this->path 251 | . (($tmp = $this->getQuery()) ? '?' . $tmp : '') 252 | . ($this->fragment === '' ? '' : '#' . $this->fragment); 253 | } 254 | 255 | 256 | /** 257 | * Returns the [user[:pass]@]host[:port] part of URI. 258 | */ 259 | public function getAuthority(): string 260 | { 261 | return $this->authority ??= $this->host === '' 262 | ? '' 263 | : ($this->user !== '' 264 | ? rawurlencode($this->user) . ($this->password === '' ? '' : ':' . rawurlencode($this->password)) . '@' 265 | : '') 266 | . $this->host 267 | . ($this->port && $this->port !== $this->getDefaultPort() 268 | ? ':' . $this->port 269 | : ''); 270 | } 271 | 272 | 273 | /** 274 | * Returns the scheme and authority part of URI. 275 | */ 276 | public function getHostUrl(): string 277 | { 278 | return ($this->scheme === '' ? '' : $this->scheme . ':') 279 | . ($this->host === '' ? '' : '//' . $this->getAuthority()); 280 | } 281 | 282 | 283 | public function __toString(): string 284 | { 285 | return $this->getAbsoluteUrl(); 286 | } 287 | 288 | 289 | public function isEqual(string|Url|self $url): bool 290 | { 291 | return (new Url($this))->isEqual($url); 292 | } 293 | 294 | 295 | /** 296 | * Resolves relative URLs in the same way as browser. If path is relative, it is resolved against 297 | * base URL, if begins with /, it is resolved against the host root. 298 | */ 299 | public function resolve(string $reference): self 300 | { 301 | $ref = new self($reference); 302 | if ($ref->scheme !== '') { 303 | $ref->path = Url::removeDotSegments($ref->path); 304 | return $ref; 305 | } 306 | 307 | $ref->scheme = $this->scheme; 308 | 309 | if ($ref->host !== '') { 310 | $ref->path = Url::removeDotSegments($ref->path); 311 | return $ref; 312 | } 313 | 314 | $ref->host = $this->host; 315 | $ref->port = $this->port; 316 | 317 | if ($ref->path === '') { 318 | $ref->path = $this->path; 319 | $ref->query = $ref->query ?: $this->query; 320 | } elseif (str_starts_with($ref->path, '/')) { 321 | $ref->path = Url::removeDotSegments($ref->path); 322 | } else { 323 | $ref->path = Url::removeDotSegments($this->mergePath($ref->path)); 324 | } 325 | return $ref; 326 | } 327 | 328 | 329 | /** @internal */ 330 | protected function mergePath(string $path): string 331 | { 332 | $pos = strrpos($this->path, '/'); 333 | return $pos === false ? $path : substr($this->path, 0, $pos + 1) . $path; 334 | } 335 | 336 | 337 | public function jsonSerialize(): string 338 | { 339 | return $this->getAbsoluteUrl(); 340 | } 341 | 342 | 343 | /** @internal */ 344 | final public function export(): array 345 | { 346 | return [$this->scheme, $this->user, $this->password, $this->host, $this->port, $this->path, $this->query, $this->fragment]; 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /src/Http/UrlScript.php: -------------------------------------------------------------------------------- 1 | 19 | * baseUrl basePath relativePath relativeUrl 20 | * | | | | 21 | * /---------------/-----\/--------\-----------------------------\ 22 | * http://nette.org/admin/script.php/pathinfo/?name=param#fragment 23 | * \_______________/\________/ 24 | * | | 25 | * scriptPath pathInfo 26 | * 27 | * 28 | * @property-read string $scriptPath 29 | * @property-read string $basePath 30 | * @property-read string $relativePath 31 | * @property-read string $baseUrl 32 | * @property-read string $relativeUrl 33 | * @property-read string $pathInfo 34 | */ 35 | class UrlScript extends UrlImmutable 36 | { 37 | private string $scriptPath; 38 | private string $basePath; 39 | 40 | 41 | public function __construct(string|Url $url = '/', string $scriptPath = '') 42 | { 43 | parent::__construct($url); 44 | $this->setScriptPath($scriptPath); 45 | } 46 | 47 | 48 | public function withPath(string $path, string $scriptPath = ''): static 49 | { 50 | $dolly = parent::withPath($path); 51 | $dolly->setScriptPath($scriptPath); 52 | return $dolly; 53 | } 54 | 55 | 56 | private function setScriptPath(string $scriptPath): void 57 | { 58 | $path = $this->getPath(); 59 | $scriptPath = $scriptPath ?: $path; 60 | $pos = strrpos($scriptPath, '/'); 61 | if ($pos === false || strncmp($scriptPath, $path, $pos + 1)) { 62 | throw new Nette\InvalidArgumentException("ScriptPath '$scriptPath' doesn't match path '$path'"); 63 | } 64 | 65 | $this->scriptPath = $scriptPath; 66 | $this->basePath = substr($scriptPath, 0, $pos + 1); 67 | } 68 | 69 | 70 | public function getScriptPath(): string 71 | { 72 | return $this->scriptPath; 73 | } 74 | 75 | 76 | public function getBasePath(): string 77 | { 78 | return $this->basePath; 79 | } 80 | 81 | 82 | public function getRelativePath(): string 83 | { 84 | return substr($this->getPath(), strlen($this->basePath)); 85 | } 86 | 87 | 88 | public function getBaseUrl(): string 89 | { 90 | return $this->getHostUrl() . $this->basePath; 91 | } 92 | 93 | 94 | public function getRelativeUrl(): string 95 | { 96 | return substr($this->getAbsoluteUrl(), strlen($this->getBaseUrl())); 97 | } 98 | 99 | 100 | /** 101 | * Returns the additional path information. 102 | */ 103 | public function getPathInfo(): string 104 | { 105 | return substr($this->getPath(), strlen($this->scriptPath)); 106 | } 107 | 108 | 109 | /** @internal */ 110 | protected function mergePath(string $path): string 111 | { 112 | return $this->basePath . $path; 113 | } 114 | } 115 | --------------------------------------------------------------------------------