Skip to content

Commit 7d18ec8

Browse files
committed
Helpers: added expirationToSeconds() unifying expiration parsing
A numeric value (including a numeric string) is taken directly as the number of seconds, a DateTimeInterface or a textual string (e.g. '20 minutes', '2024-01-01') is resolved as an absolute time, and null means "no value". An empty string is rejected as it is never meaningful. The helper is a pure parser and applies no policy - each caller decides what null or a non-positive result means in its own context: - Response::setExpiration(): null or a non-positive time disables caching - Session::setExpiration(): null restores the default lifetime, a non-positive time throws (a lifetime in the past makes no sense) - SessionSection::setExpiration(): null clears the expiration - Response::setCookie(): null is a session cookie, a non-positive time deletes it; passing integer 0 (which used to mean a session cookie) is deprecated in favour of null
1 parent 0b13a70 commit 7d18ec8

6 files changed

Lines changed: 91 additions & 31 deletions

File tree

src/Http/Helpers.php

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,24 @@ public static function formatDate(string|int|\DateTimeInterface $time): string
3636
}
3737

3838

39+
/**
40+
* Converts an expiration value to the number of seconds from now (may be negative for the past).
41+
* A numeric value (including a numeric string) is taken directly as the number of seconds; a
42+
* DateTimeInterface or a textual string (e.g. '20 minutes', '2024-01-01') is resolved as an absolute
43+
* time. Returns null for null (no value); an empty string is rejected as it is never meaningful.
44+
* @throws Nette\InvalidArgumentException for an empty string
45+
*/
46+
public static function expirationToSeconds(string|int|\DateTimeInterface|null $expire): ?int
47+
{
48+
return match (true) {
49+
$expire === null => null,
50+
$expire === '' => throw new Nette\InvalidArgumentException('Expiration must not be an empty string; use null instead.'),
51+
is_numeric($expire) => (int) $expire,
52+
default => DateTime::from($expire)->getTimestamp() - time(),
53+
};
54+
}
55+
56+
3957
/**
4058
* Parses an HTTP quality-value list such as the Accept, Accept-Language or Accept-Encoding header
4159
* into tokens mapped to their q-factor, ordered by descending preference. Tokens are lowercased and
@@ -83,6 +101,6 @@ public static function ipMatch(string $ip, string $mask): bool
83101
*/
84102
public static function initCookie(IRequest $request, IResponse $response): void
85103
{
86-
$response->setCookie(self::StrictCookieName, '1', 0, '/', sameSite: IResponse::SameSiteStrict);
104+
$response->setCookie(self::StrictCookieName, '1', null, '/', sameSite: IResponse::SameSiteStrict);
87105
}
88106
}

src/Http/Response.php

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
namespace Nette\Http;
99

1010
use Nette;
11-
use Nette\Utils\DateTime;
1211
use function array_filter, header, header_remove, headers_list, headers_sent, htmlspecialchars, http_response_code, ini_get, is_int, ltrim, ob_get_length, ob_get_status, preg_match, rawurlencode, setcookie, str_replace, strcasecmp, strlen, strncasecmp, substr, time;
1312
use const PHP_SAPI;
1413

@@ -166,16 +165,16 @@ public function redirect(string $url, int $code = self::S302_Found): void
166165
*/
167166
public function setExpiration(?string $expire): static
168167
{
168+
$seconds = Helpers::expirationToSeconds($expire);
169169
$this->setHeader('Pragma', null);
170-
if (!$expire) { // no cache
170+
if ($seconds === null || $seconds <= 0) { // no cache
171171
$this->setHeader('Cache-Control', 's-maxage=0, max-age=0, must-revalidate');
172172
$this->setHeader('Expires', 'Mon, 23 Jan 1978 10:00:00 GMT');
173173
return $this;
174174
}
175175

176-
$expire = DateTime::from($expire);
177-
$this->setHeader('Cache-Control', 'max-age=' . ($expire->format('U') - time()));
178-
$this->setHeader('Expires', Helpers::formatDate($expire));
176+
$this->setHeader('Cache-Control', 'max-age=' . $seconds);
177+
$this->setHeader('Expires', Helpers::formatDate(time() + $seconds));
179178
return $this;
180179
}
181180

@@ -241,8 +240,14 @@ public function setCookie(
241240
): static
242241
{
243242
self::checkHeaders();
243+
if ($expire === 0) { // BC: 0 used to mean a session cookie
244+
trigger_error('Passing 0 as $expire is deprecated; use null for a session cookie.', E_USER_DEPRECATED);
245+
$expire = null;
246+
}
247+
248+
$seconds = Helpers::expirationToSeconds($expire);
244249
setcookie($name, $value, [
245-
'expires' => $expire ? (int) DateTime::from($expire)->format('U') : 0,
250+
'expires' => $seconds === null ? 0 : time() + $seconds,
246251
'path' => $path ?? ($domain ? '/' : $this->cookiePath),
247252
'domain' => $domain ?? ($path ? '' : $this->cookieDomain),
248253
'secure' => $secure ?? $this->cookieSecure,
@@ -264,7 +269,7 @@ public function deleteCookie(
264269
?bool $secure = null,
265270
): void
266271
{
267-
$this->setCookie($name, '', 0, $path, $domain, $secure);
272+
$this->setCookie($name, '', null, $path, $domain, $secure);
268273
}
269274

270275

src/Http/Session.php

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -485,19 +485,20 @@ private function configure(array $config): void
485485
*/
486486
public function setExpiration(?string $expire): static
487487
{
488-
if ($expire === null) {
488+
$seconds = Helpers::expirationToSeconds($expire);
489+
if ($seconds === null) {
489490
return $this->setOptions([
490491
'gc_maxlifetime' => self::DefaultFileLifetime,
491492
'cookie_lifetime' => 0,
492493
]);
493-
494-
} else {
495-
$expire = Nette\Utils\DateTime::from($expire)->format('U') - time();
496-
return $this->setOptions([
497-
'gc_maxlifetime' => $expire,
498-
'cookie_lifetime' => $expire,
499-
]);
494+
} elseif ($seconds <= 0) {
495+
throw new Nette\InvalidArgumentException("Session expiration must be in the future, '$expire' given.");
500496
}
497+
498+
return $this->setOptions([
499+
'gc_maxlifetime' => $seconds,
500+
'cookie_lifetime' => $seconds,
501+
]);
501502
}
502503

503504

@@ -554,7 +555,7 @@ private function sendCookie(): void
554555
$this->response->setCookie(
555556
session_name(),
556557
session_id(),
557-
$cookie['lifetime'] ? $cookie['lifetime'] + time() : 0,
558+
$cookie['lifetime'] ? $cookie['lifetime'] + time() : null,
558559
$cookie['path'],
559560
$cookie['domain'],
560561
$cookie['secure'],

src/Http/SessionSection.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
namespace Nette\Http;
99

10-
use Nette;
1110
use function array_key_exists, func_num_args, ini_get, is_array, is_string, time;
1211

1312

@@ -192,21 +191,22 @@ public function offsetUnset($name): void
192191
*/
193192
public function setExpiration(?string $expire, string|array|null $variables = null): static
194193
{
195-
$this->session->autoStart((bool) $expire);
194+
$seconds = Helpers::expirationToSeconds($expire);
195+
$this->session->autoStart($seconds !== null);
196196
$meta = &$this->getMeta();
197-
if ($expire) {
198-
$expire = Nette\Utils\DateTime::from($expire)->format('U');
197+
if ($seconds !== null) {
199198
$max = (int) ini_get('session.gc_maxlifetime');
200199
if (
201200
$max !== 0 // 0 - unlimited in memcache handler
202-
&& ($expire - time() > $max + 3) // 3 - bulgarian constant
201+
&& ($seconds > $max + 3) // 3 - bulgarian constant
203202
) {
204203
trigger_error("The expiration time is greater than the session expiration $max seconds");
205204
}
206205
}
207206

207+
$time = $seconds === null ? null : time() + $seconds;
208208
foreach (is_array($variables) ? $variables : [$variables] as $variable) {
209-
$meta[$variable ?? '']['T'] = $expire ?: null;
209+
$meta[$variable ?? '']['T'] = $time;
210210
}
211211

212212
return $this;

tests/Http/Helpers.phpt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,27 @@ test('date formatting', function () {
7171
Assert::same('Tue, 15 Nov 1994 08:12:31 GMT', Helpers::formatDate(new DateTime('1994-11-15T06:12:31-0200')));
7272
Assert::same('Tue, 15 Nov 1994 08:12:31 GMT', Helpers::formatDate(784_887_151));
7373
});
74+
75+
76+
test('expirationToSeconds', function () {
77+
// null means no value
78+
Assert::null(Helpers::expirationToSeconds(null));
79+
80+
// an empty string is never meaningful
81+
Assert::exception(
82+
fn() => Helpers::expirationToSeconds(''),
83+
Nette\InvalidArgumentException::class,
84+
);
85+
86+
// a numeric value (int or string) is the number of seconds directly
87+
Assert::same(0, Helpers::expirationToSeconds(0));
88+
Assert::same(0, Helpers::expirationToSeconds('0'));
89+
Assert::same(3600, Helpers::expirationToSeconds(3600));
90+
Assert::same(3600, Helpers::expirationToSeconds('3600'));
91+
Assert::same(-5, Helpers::expirationToSeconds(-5));
92+
Assert::same(-5, Helpers::expirationToSeconds('-5'));
93+
94+
// a textual or DateTime value is resolved as an absolute time, relative to now
95+
Assert::true(abs(Helpers::expirationToSeconds('+1 hour') - 3600) <= 1);
96+
Assert::true(abs(Helpers::expirationToSeconds(new DateTime('+1 hour')) - 3600) <= 1);
97+
});

tests/Http/Response.setCookie.phpt

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,12 @@ $old = headers_list();
1818
$response = new Http\Response;
1919

2020

21-
$response->setCookie('test', 'value', 0);
21+
$response->setCookie('test', 'value', null);
2222
$headers = array_values(array_diff(headers_list(), $old, ['Set-Cookie:']));
2323
Assert::same(['Set-Cookie: test=value; path=/; HttpOnly; SameSite=Lax'], $headers);
2424

2525

26-
$response->setCookie('test', 'newvalue', 0);
26+
$response->setCookie('test', 'newvalue', null);
2727
$headers = array_values(array_diff(headers_list(), $old, ['Set-Cookie:']));
2828
Assert::same(['Set-Cookie: test=value; path=/; HttpOnly; SameSite=Lax', 'Set-Cookie: test=newvalue; path=/; HttpOnly; SameSite=Lax'], $headers);
2929

@@ -32,19 +32,19 @@ Assert::same(['Set-Cookie: test=value; path=/; HttpOnly; SameSite=Lax', 'Set-Coo
3232
$response = new Http\Response;
3333
$response->cookiePath = '/foo';
3434
$old = headers_list();
35-
$response->setCookie('test', 'a', 0);
35+
$response->setCookie('test', 'a', null);
3636
$headers = array_values(array_diff(headers_list(), $old, ['Set-Cookie:']));
3737
Assert::same(['Set-Cookie: test=a; path=/foo; HttpOnly; SameSite=Lax'], $headers);
3838

3939
// cookiePath + path
4040
$old = headers_list();
41-
$response->setCookie('test', 'b', 0, '/bar');
41+
$response->setCookie('test', 'b', null, '/bar');
4242
$headers = array_values(array_diff(headers_list(), $old, ['Set-Cookie:']));
4343
Assert::same(['Set-Cookie: test=b; path=/bar; HttpOnly; SameSite=Lax'], $headers);
4444

4545
// cookiePath + domain
4646
$old = headers_list();
47-
$response->setCookie('test', 'c', 0, null, 'nette.org');
47+
$response->setCookie('test', 'c', null, null, 'nette.org');
4848
$headers = array_values(array_diff(headers_list(), $old, ['Set-Cookie:']));
4949
Assert::same(['Set-Cookie: test=c; path=/; domain=nette.org; HttpOnly; SameSite=Lax'], $headers);
5050

@@ -53,18 +53,30 @@ Assert::same(['Set-Cookie: test=c; path=/; domain=nette.org; HttpOnly; SameSite=
5353
$response = new Http\Response;
5454
$response->cookieDomain = 'nette.org';
5555
$old = headers_list();
56-
$response->setCookie('test', 'd', 0);
56+
$response->setCookie('test', 'd', null);
5757
$headers = array_values(array_diff(headers_list(), $old, ['Set-Cookie:']));
5858
Assert::same(['Set-Cookie: test=d; path=/; domain=nette.org; HttpOnly; SameSite=Lax'], $headers);
5959

6060
// cookieDomain + path
6161
$old = headers_list();
62-
$response->setCookie('test', 'e', 0, '/bar');
62+
$response->setCookie('test', 'e', null, '/bar');
6363
$headers = array_values(array_diff(headers_list(), $old, ['Set-Cookie:']));
6464
Assert::same(['Set-Cookie: test=e; path=/bar; HttpOnly; SameSite=Lax'], $headers);
6565

6666
// cookieDomain + domain
6767
$old = headers_list();
68-
$response->setCookie('test', 'f', 0, null, 'example.org');
68+
$response->setCookie('test', 'f', null, null, 'example.org');
6969
$headers = array_values(array_diff(headers_list(), $old, ['Set-Cookie:']));
7070
Assert::same(['Set-Cookie: test=f; path=/; domain=example.org; HttpOnly; SameSite=Lax'], $headers);
71+
72+
73+
// integer 0 is deprecated, but kept as a session cookie for BC
74+
$response = new Http\Response;
75+
$old = headers_list();
76+
Assert::error(
77+
fn() => $response->setCookie('test', 'g', 0),
78+
E_USER_DEPRECATED,
79+
'Passing 0 as $expire is deprecated; use null for a session cookie.',
80+
);
81+
$headers = array_values(array_diff(headers_list(), $old, ['Set-Cookie:']));
82+
Assert::same(['Set-Cookie: test=g; path=/; HttpOnly; SameSite=Lax'], $headers);

0 commit comments

Comments
 (0)