From 49597838eb285283c2b3a19d71455c3d5e3fdcbf Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Mon, 24 Jul 2023 23:51:43 +0200 Subject: [PATCH 1/5] Use immutable DateTime Ensure DateTime cannot be accidentally mutated when passed as an argument. --- src/controllers/Index.php | 2 +- src/controllers/Items/Sync.php | 16 ++++++++-------- src/controllers/Rss.php | 6 +++--- src/daos/ItemOptions.php | 10 +++++----- src/daos/Items.php | 9 ++++----- src/daos/ItemsInterface.php | 19 +++++++++---------- src/daos/StatementsInterface.php | 4 +--- src/daos/mysql/Items.php | 21 ++++++++++----------- src/daos/mysql/Statements.php | 8 +++----- src/daos/pgsql/Statements.php | 2 +- src/daos/sqlite/Statements.php | 6 ++---- src/helpers/ContentLoader.php | 10 +++++----- src/helpers/ViewHelper.php | 8 ++++---- 13 files changed, 56 insertions(+), 65 deletions(-) diff --git a/src/controllers/Index.php b/src/controllers/Index.php index 343bbc9eca..8027545624 100644 --- a/src/controllers/Index.php +++ b/src/controllers/Index.php @@ -83,7 +83,7 @@ public function home(): void { $lastUpdate = $this->itemsDao->lastUpdate(); $result = [ - 'lastUpdate' => $lastUpdate !== null ? $lastUpdate->format(\DateTime::ATOM) : null, + 'lastUpdate' => $lastUpdate !== null ? $lastUpdate->format(\DateTimeImmutable::ATOM) : null, 'hasMore' => $items['hasMore'], 'entries' => $items['entries'], 'all' => $statsAll, diff --git a/src/controllers/Items/Sync.php b/src/controllers/Items/Sync.php index c671231be0..9c47b68660 100644 --- a/src/controllers/Items/Sync.php +++ b/src/controllers/Items/Sync.php @@ -49,26 +49,26 @@ public function sync(): void { $this->view->jsonError(['sync' => 'missing since argument']); } - $since = new \DateTime($params['since']); - $since->setTimeZone(new \DateTimeZone(date_default_timezone_get())); + $since = new \DateTimeImmutable($params['since']); + $since = $since->setTimeZone(new \DateTimeZone(date_default_timezone_get())); $lastUpdate = $this->itemsDao->lastUpdate(); $sync = [ - 'lastUpdate' => $lastUpdate !== null ? $lastUpdate->format(\DateTime::ATOM) : null, + 'lastUpdate' => $lastUpdate !== null ? $lastUpdate->format(\DateTimeImmutable::ATOM) : null, ]; if (array_key_exists('itemsSinceId', $params)) { $sinceId = (int) $params['itemsSinceId']; if ($sinceId >= 0) { - $notBefore = isset($params['itemsNotBefore']) ? new \DateTime($params['itemsNotBefore']) : null; + $notBefore = isset($params['itemsNotBefore']) ? new \DateTimeImmutable($params['itemsNotBefore']) : null; if ($sinceId === 0 || !$notBefore) { $sinceId = $this->itemsDao->lowestIdOfInterest() - 1; // only send 1 day worth of items - $notBefore = new \DateTime(); - $notBefore->setTimeZone(new \DateTimeZone(date_default_timezone_get())); - $notBefore->sub(new \DateInterval('P1D')); - $notBefore->setTimeZone(new \DateTimeZone(date_default_timezone_get())); + $notBefore = new \DateTimeImmutable(); + $notBefore = $notBefore->setTimeZone(new \DateTimeZone(date_default_timezone_get())); + $notBefore = $notBefore->sub(new \DateInterval('P1D')); + $notBefore = $notBefore->setTimeZone(new \DateTimeZone(date_default_timezone_get())); } $itemsHowMany = $this->configuration->itemsPerpage; diff --git a/src/controllers/Rss.php b/src/controllers/Rss.php index b460baaa13..2b96ef1b6b 100644 --- a/src/controllers/Rss.php +++ b/src/controllers/Rss.php @@ -75,7 +75,7 @@ public function rss(): void { $newItem->setTitle($this->sanitizeTitle($item['title'] . ' (' . $lastSourceName . ')')); @$newItem->setLink($item['link']); @$newItem->setId($item['link']); - $newItem->setDate($item['datetime']); + $newItem->setDate(DateTime::createFromImmutable($item['datetime'])); $newItem->setDescription(str_replace('"', '"', $item['content'])); // add tags in category node @@ -96,9 +96,9 @@ public function rss(): void { } if ($newestEntryDate === null) { - $newestEntryDate = new \DateTime(); + $newestEntryDate = new \DateTimeImmutable(); } - $this->feedWriter->setDate($newestEntryDate); + $this->feedWriter->setDate(DateTime::createFromImmutable($newestEntryDate)); $this->feedWriter->printFeed(); } diff --git a/src/daos/ItemOptions.php b/src/daos/ItemOptions.php index 0bb56489b5..561dc81622 100644 --- a/src/daos/ItemOptions.php +++ b/src/daos/ItemOptions.php @@ -4,7 +4,7 @@ namespace daos; -use DateTime; +use DateTimeImmutable; /** * Object holding parameters for querying items. @@ -24,13 +24,13 @@ final class ItemOptions { public ?int $pageSize = null; /** @readonly */ - public ?DateTime $fromDatetime = null; + public ?DateTimeImmutable $fromDatetime = null; /** @readonly */ public ?int $fromId = null; /** @readonly */ - public ?DateTime $updatedSince = null; + public ?DateTimeImmutable $updatedSince = null; /** @readonly */ public ?string $tag = null; @@ -71,7 +71,7 @@ public static function fromUser(array $data): self { } if (isset($data['fromDatetime']) && is_string($data['fromDatetime']) && strlen($data['fromDatetime']) > 0) { - $options->fromDatetime = new \DateTime($data['fromDatetime']); + $options->fromDatetime = new \DateTimeImmutable($data['fromDatetime']); } if (isset($data['fromId']) && is_numeric($data['fromId'])) { @@ -79,7 +79,7 @@ public static function fromUser(array $data): self { } if (isset($data['updatedsince']) && is_string($data['updatedsince']) && strlen($data['updatedsince']) > 0) { - $options->updatedSince = new \DateTime($data['updatedsince']); + $options->updatedSince = new \DateTimeImmutable($data['updatedsince']); } if (isset($data['tag']) && is_string($data['tag']) && strlen($tag = trim($data['tag'])) > 0) { diff --git a/src/daos/Items.php b/src/daos/Items.php index 533174d73f..1416bcafb1 100644 --- a/src/daos/Items.php +++ b/src/daos/Items.php @@ -4,7 +4,6 @@ namespace daos; -use DateTime; use DateTimeImmutable; use helpers\Authentication; @@ -60,7 +59,7 @@ public function updateLastSeen(array $itemIds): void { $this->backend->updateLastSeen($itemIds); } - public function cleanup(?DateTime $minDate): void { + public function cleanup(?DateTimeImmutable $minDate): void { $this->backend->cleanup($minDate); } @@ -69,7 +68,7 @@ public function cleanup(?DateTime $minDate): void { * * @param ItemOptions $options search, offset and filter params * - * @return array items as array + * @return array items as array */ public function get(ItemOptions $options): array { $items = $this->backend->get($options); @@ -107,7 +106,7 @@ public function hasMore(): bool { return $this->backend->hasMore(); } - public function sync(int $sinceId, DateTime $notBefore, DateTime $since, int $howMany): array { + public function sync(int $sinceId, DateTimeImmutable $notBefore, DateTimeImmutable $since, int $howMany): array { return $this->backend->sync($sinceId, $notBefore, $since, $howMany); } @@ -147,7 +146,7 @@ public function lastUpdate(): ?DateTimeImmutable { return $this->backend->lastUpdate(); } - public function statuses(DateTime $since): array { + public function statuses(DateTimeImmutable $since): array { return $this->backend->statuses($since); } diff --git a/src/daos/ItemsInterface.php b/src/daos/ItemsInterface.php index 816ada0817..affe72ba2f 100644 --- a/src/daos/ItemsInterface.php +++ b/src/daos/ItemsInterface.php @@ -4,7 +4,6 @@ namespace daos; -use DateTime; use DateTimeImmutable; use helpers\HtmlString; @@ -73,16 +72,16 @@ public function updateLastSeen(array $itemIds): void; /** * cleanup orphaned and old items * - * @param ?DateTime $date date to delete all items older than this value + * @param ?DateTimeImmutable $date date to delete all items older than this value */ - public function cleanup(?DateTime $date): void; + public function cleanup(?DateTimeImmutable $date): void; /** * returns items * * @param ItemOptions $options search, offset and filter params * - * @return array items as array + * @return array items as array */ public function get(ItemOptions $options): array; @@ -96,12 +95,12 @@ public function hasMore(): bool; * Obtain new or changed items in the database for synchronization with clients. * * @param int $sinceId id of last seen item - * @param DateTime $notBefore cut off time stamp - * @param DateTime $since timestamp of last seen item + * @param DateTimeImmutable $notBefore cut off time stamp + * @param DateTimeImmutable $since timestamp of last seen item * - * @return array of items + * @return array of items */ - public function sync(int $sinceId, DateTime $notBefore, DateTime $since, int $howMany): array; + public function sync(int $sinceId, DateTimeImmutable $notBefore, DateTimeImmutable $since, int $howMany): array; /** * Lowest id of interest @@ -171,11 +170,11 @@ public function lastUpdate(): ?DateTimeImmutable; /** * returns the statuses of items last update * - * @param DateTime $since minimal date of returned items + * @param DateTimeImmutable $since minimal date of returned items * * @return array of unread, starred, etc. status of specified items */ - public function statuses(DateTime $since): array; + public function statuses(DateTimeImmutable $since): array; /** * bulk update of item status diff --git a/src/daos/StatementsInterface.php b/src/daos/StatementsInterface.php index ce045ab906..0fef3648c3 100644 --- a/src/daos/StatementsInterface.php +++ b/src/daos/StatementsInterface.php @@ -93,11 +93,9 @@ public static function bool(bool $bool): string; * Convert a date into a representation suitable for comparison by * the database engine. * - * @param \DateTime $date datetime - * * @return string representation of datetime */ - public static function datetime(\DateTime $date): string; + public static function datetime(\DateTimeImmutable $date): string; /** * Ensure row values have the appropriate PHP type. This assumes we are diff --git a/src/daos/mysql/Items.php b/src/daos/mysql/Items.php index f8b3e4d687..e78a315966 100644 --- a/src/daos/mysql/Items.php +++ b/src/daos/mysql/Items.php @@ -6,7 +6,6 @@ use daos\DatabaseInterface; use daos\ItemOptions; -use DateTime; use DateTimeImmutable; use helpers\Configuration; use helpers\HtmlString; @@ -206,9 +205,9 @@ public function updateLastSeen(array $itemIds): void { /** * cleanup orphaned and old items * - * @param ?DateTime $date date to delete all items older than this value + * @param ?DateTimeImmutable $date date to delete all items older than this value */ - public function cleanup(?DateTime $date): void { + public function cleanup(?DateTimeImmutable $date): void { $this->database->exec('DELETE FROM ' . $this->configuration->dbPrefix . 'items WHERE source NOT IN ( SELECT id FROM ' . $this->configuration->dbPrefix . 'sources)'); @@ -226,7 +225,7 @@ public function cleanup(?DateTime $date): void { * * @param ItemOptions $options search, offset and filter params * - * @return array items as array + * @return array items as array */ public function get(ItemOptions $options): array { $params = []; @@ -375,12 +374,12 @@ public function hasMore(): bool { * Obtain new or changed items in the database for synchronization with clients. * * @param int $sinceId id of last seen item - * @param DateTime $notBefore cut off time stamp - * @param DateTime $since timestamp of last seen item + * @param DateTimeImmutable $notBefore cut off time stamp + * @param DateTimeImmutable $since timestamp of last seen item * - * @return array of items + * @return array of items */ - public function sync(int $sinceId, DateTime $notBefore, DateTime $since, int $howMany): array { + public function sync(int $sinceId, DateTimeImmutable $notBefore, DateTimeImmutable $since, int $howMany): array { $query = 'SELECT items.id, datetime, items.title AS title, content, unread, starred, source, thumbnail, icon, uid, link, updatetime, author, sources.title as sourcetitle, sources.tags as tags FROM ' . $this->configuration->dbPrefix . 'items AS items, ' . $this->configuration->dbPrefix . 'sources AS sources @@ -575,11 +574,11 @@ public function lastUpdate(): ?DateTimeImmutable { /** * returns the statuses of items last update * - * @param DateTime $since minimal date of returned items + * @param DateTimeImmutable $since minimal date of returned items * * @return array of unread, starred, etc. status of specified items */ - public function statuses(DateTime $since): array { + public function statuses(DateTimeImmutable $since): array { $res = $this->database->exec( 'SELECT id, unread, starred FROM ' . $this->configuration->dbPrefix . 'items @@ -627,7 +626,7 @@ public function bulkStatusUpdate(array $statuses): void { // sanitize update time if (array_key_exists('datetime', $status)) { - $updateDate = new \DateTime($status['datetime']); + $updateDate = new \DateTimeImmutable($status['datetime']); } else { $updateDate = null; } diff --git a/src/daos/mysql/Statements.php b/src/daos/mysql/Statements.php index bdd9f63599..212f1ff16d 100644 --- a/src/daos/mysql/Statements.php +++ b/src/daos/mysql/Statements.php @@ -128,13 +128,11 @@ public static function bool(bool $bool): string { * Convert a date into a representation suitable for comparison by * the database engine. * - * @param \DateTime $date datetime - * * @return string representation of datetime */ - public static function datetime(\DateTime $date): string { + public static function datetime(\DateTimeImmutable $date): string { // mysql supports ISO8601 datetime comparisons - return $date->format(\DateTime::ATOM); + return $date->format(\DateTimeImmutable::ATOM); } /** @@ -179,7 +177,7 @@ public static function ensureRowTypes(array $rows, array $expectedRowTypes): arr } break; case DatabaseInterface::PARAM_DATETIME: - $value = new \DateTime($row[$columnIndex]); + $value = new \DateTimeImmutable($row[$columnIndex]); break; default: $value = null; diff --git a/src/daos/pgsql/Statements.php b/src/daos/pgsql/Statements.php index 2f44c36f33..8e80206336 100644 --- a/src/daos/pgsql/Statements.php +++ b/src/daos/pgsql/Statements.php @@ -104,7 +104,7 @@ public static function ensureRowTypes(array $rows, array $expectedRowTypes): arr } break; case DatabaseInterface::PARAM_DATETIME: - $value = new \DateTime($row[$columnIndex]); + $value = new \DateTimeImmutable($row[$columnIndex]); break; default: $value = null; diff --git a/src/daos/sqlite/Statements.php b/src/daos/sqlite/Statements.php index 6362000c21..2943167a8c 100644 --- a/src/daos/sqlite/Statements.php +++ b/src/daos/sqlite/Statements.php @@ -47,17 +47,15 @@ public static function bool(bool $bool): string { * Convert a date into a representation suitable for comparison by * the database engine. * - * @param \DateTime $date datetime - * * @return string representation of datetime */ - public static function datetime(\DateTime $date): string { + public static function datetime(\DateTimeImmutable $date): string { // SQLite does not support timezones. // The client previously sent the local timezone // but now it sends UTC time so we need to adjust it here // to avoid fromDatetime mismatch. // TODO: Switch to UTC everywhere. - $date->setTimeZone((new \DateTime())->getTimeZone()); + $date = $date->setTimeZone((new \DateTimeImmutable())->getTimeZone()); return $date->format('Y-m-d H:i:s'); } diff --git a/src/helpers/ContentLoader.php b/src/helpers/ContentLoader.php index de588e1090..0c4e25d1df 100644 --- a/src/helpers/ContentLoader.php +++ b/src/helpers/ContentLoader.php @@ -121,8 +121,8 @@ public function fetch($source): void { // current date $minDate = null; if ($this->configuration->itemsLifetime !== 0) { - $minDate = new \DateTime(); - $minDate->sub(new \DateInterval('P' . $this->configuration->itemsLifetime . 'D')); + $minDate = new \DateTimeImmutable(); + $minDate = $minDate->sub(new \DateInterval('P' . $this->configuration->itemsLifetime . 'D')); $this->logger->debug('minimum date: ' . $minDate->format('Y-m-d H:i:s')); } @@ -168,7 +168,7 @@ public function fetch($source): void { $itemDate = new \DateTimeImmutable(); } if ($minDate !== null && $itemDate < $minDate) { - $this->logger->debug('item "' . $titlePlainText . '" (' . $itemDate->format(\DateTime::ATOM) . ') older than ' . $this->configuration->itemsLifetime . ' days'); + $this->logger->debug('item "' . $titlePlainText . '" (' . $itemDate->format(\DateTimeImmutable::ATOM) . ') older than ' . $this->configuration->itemsLifetime . ' days'); continue; } @@ -426,8 +426,8 @@ public function cleanup(): void { $this->logger->debug('cleanup orphaned and old items'); $minDate = null; if ($this->configuration->itemsLifetime !== 0) { - $minDate = new \DateTime(); - $minDate->sub(new \DateInterval('P' . $this->configuration->itemsLifetime . 'D')); + $minDate = new \DateTimeImmutable(); + $minDate = $minDate->sub(new \DateInterval('P' . $this->configuration->itemsLifetime . 'D')); } $this->itemsDao->cleanup($minDate); $this->logger->debug('cleanup orphaned and old items finished'); diff --git a/src/helpers/ViewHelper.php b/src/helpers/ViewHelper.php index 6a39f90a7e..4e86a2fb70 100644 --- a/src/helpers/ViewHelper.php +++ b/src/helpers/ViewHelper.php @@ -4,7 +4,7 @@ namespace helpers; -use DateTime; +use DateTimeImmutable; /** * Helper class for loading extern items @@ -122,7 +122,7 @@ public function camoflauge(string $content): string { /** * Prepare entry as expected by the client. * - * @param array{title: string, content: string, datetime: DateTime, updatetime: DateTime, sourcetitle: string, tags: string[]} $item item to modify + * @param array{title: string, content: string, datetime: DateTimeImmutable, updatetime: DateTimeImmutable, sourcetitle: string, tags: string[]} $item item to modify * @param \controllers\Tags $tagsController tags controller * @param ?array $tags list of tags * @param ?string $search search query @@ -151,8 +151,8 @@ public function preprocessEntry(array $item, \controllers\Tags $tagsController, $item['content'] = ViewHelper::lazyimg($item['content']); $contentWithoutTags = strip_tags($item['content']); $item['wordCount'] = str_word_count($contentWithoutTags); - $item['datetime'] = $item['datetime']->format(\DateTime::ATOM); - $item['updatetime'] = $item['updatetime']->format(\DateTime::ATOM); + $item['datetime'] = $item['datetime']->format(\DateTimeImmutable::ATOM); + $item['updatetime'] = $item['updatetime']->format(\DateTimeImmutable::ATOM); $item['lengthWithoutTags'] = strlen($contentWithoutTags); return $item; From 4938212eaefe4604eec3e11087b9f90bb402a566 Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Tue, 25 Jul 2023 00:22:55 +0200 Subject: [PATCH 2/5] wip: Use UTC internally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit According to https://dev.mysql.com/doc/refman/8.0/en/time-zone-support.html > MySQL converts `TIMESTAMP` values from the current time zone to UTC for storage, and back from UTC to the current time zone for retrieval. (This does not occur for other types such as `DATETIME`.) That is bad. - [ ] Check that we do not use TIMESTAMP anywhere or set MySQL timezone to UTC. - [ ] Verify that PostgreSQL includes timezone offsets when selecting datetime values with timezone. - [ ] Check that MySQL correctly compares datetimes in db with values containing tz offset as produced by `daos\mysql\statements::datetime()`. - [ ] Add migrations for sqlite local → UTC when `date.timezone` is not UTC. --- src/controllers/Items/Sync.php | 6 ++---- src/daos/ItemOptions.php | 6 ++++-- src/daos/mysql/Items.php | 7 +++++-- src/daos/mysql/Statements.php | 3 ++- src/daos/pgsql/Statements.php | 1 + src/daos/sqlite/Statements.php | 10 +++------- 6 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/controllers/Items/Sync.php b/src/controllers/Items/Sync.php index 9c47b68660..85fe90541f 100644 --- a/src/controllers/Items/Sync.php +++ b/src/controllers/Items/Sync.php @@ -49,8 +49,8 @@ public function sync(): void { $this->view->jsonError(['sync' => 'missing since argument']); } - $since = new \DateTimeImmutable($params['since']); - $since = $since->setTimeZone(new \DateTimeZone(date_default_timezone_get())); + // The client should include a timezone offset but let’s default to UTC in case it does not. + $since = new \DateTimeImmutable($params['since'], new \DateTimeZone('UTC')); $lastUpdate = $this->itemsDao->lastUpdate(); @@ -66,9 +66,7 @@ public function sync(): void { $sinceId = $this->itemsDao->lowestIdOfInterest() - 1; // only send 1 day worth of items $notBefore = new \DateTimeImmutable(); - $notBefore = $notBefore->setTimeZone(new \DateTimeZone(date_default_timezone_get())); $notBefore = $notBefore->sub(new \DateInterval('P1D')); - $notBefore = $notBefore->setTimeZone(new \DateTimeZone(date_default_timezone_get())); } $itemsHowMany = $this->configuration->itemsPerpage; diff --git a/src/daos/ItemOptions.php b/src/daos/ItemOptions.php index 561dc81622..24ac9fbd4c 100644 --- a/src/daos/ItemOptions.php +++ b/src/daos/ItemOptions.php @@ -71,7 +71,8 @@ public static function fromUser(array $data): self { } if (isset($data['fromDatetime']) && is_string($data['fromDatetime']) && strlen($data['fromDatetime']) > 0) { - $options->fromDatetime = new \DateTimeImmutable($data['fromDatetime']); + // The client should include a timezone offset but let’s default to UTC in case it does not. + $options->fromDatetime = new \DateTimeImmutable($data['fromDatetime'], new \DateTimeZone('UTC')); } if (isset($data['fromId']) && is_numeric($data['fromId'])) { @@ -79,7 +80,8 @@ public static function fromUser(array $data): self { } if (isset($data['updatedsince']) && is_string($data['updatedsince']) && strlen($data['updatedsince']) > 0) { - $options->updatedSince = new \DateTimeImmutable($data['updatedsince']); + // The client should include a timezone offset but let’s default to UTC in case it does not. + $options->updatedSince = new \DateTimeImmutable($data['updatedsince'], new \DateTimeZone('UTC')); } if (isset($data['tag']) && is_string($data['tag']) && strlen($tag = trim($data['tag'])) > 0) { diff --git a/src/daos/mysql/Items.php b/src/daos/mysql/Items.php index e78a315966..62a51c90bb 100644 --- a/src/daos/mysql/Items.php +++ b/src/daos/mysql/Items.php @@ -568,7 +568,9 @@ public function lastUpdate(): ?DateTimeImmutable { FROM ' . $this->configuration->dbPrefix . 'items;'); $lastUpdate = $res[0]['last_update_time']; - return $lastUpdate !== null ? new DateTimeImmutable($lastUpdate) : null; + // MySQL and SQLite do not support timezones, load it as UTC. + // PostgreSQL will include the timezone offset as the part of the returned value and it will take precedence. + return $lastUpdate !== null ? new DateTimeImmutable($lastUpdate, new \DateTimeZone('UTC')) : null; } /** @@ -626,7 +628,8 @@ public function bulkStatusUpdate(array $statuses): void { // sanitize update time if (array_key_exists('datetime', $status)) { - $updateDate = new \DateTimeImmutable($status['datetime']); + // The client should include a timezone offset but let’s default to UTC in case it does not. + $updateDate = new \DateTimeImmutable($status['datetime'], new \DateTimeZone('UTC')); } else { $updateDate = null; } diff --git a/src/daos/mysql/Statements.php b/src/daos/mysql/Statements.php index 212f1ff16d..084918f64f 100644 --- a/src/daos/mysql/Statements.php +++ b/src/daos/mysql/Statements.php @@ -177,7 +177,8 @@ public static function ensureRowTypes(array $rows, array $expectedRowTypes): arr } break; case DatabaseInterface::PARAM_DATETIME: - $value = new \DateTimeImmutable($row[$columnIndex]); + // MySQL and SQLite do not support timezones, load it as UTC. + $value = new \DateTimeImmutable($row[$columnIndex], new \DateTimeZone('UTC')); break; default: $value = null; diff --git a/src/daos/pgsql/Statements.php b/src/daos/pgsql/Statements.php index 8e80206336..b910a7a8bd 100644 --- a/src/daos/pgsql/Statements.php +++ b/src/daos/pgsql/Statements.php @@ -104,6 +104,7 @@ public static function ensureRowTypes(array $rows, array $expectedRowTypes): arr } break; case DatabaseInterface::PARAM_DATETIME: + // PostgreSQL will include the timezone offset as the part of the returned value. $value = new \DateTimeImmutable($row[$columnIndex]); break; default: diff --git a/src/daos/sqlite/Statements.php b/src/daos/sqlite/Statements.php index 2943167a8c..4bf28f212b 100644 --- a/src/daos/sqlite/Statements.php +++ b/src/daos/sqlite/Statements.php @@ -50,14 +50,10 @@ public static function bool(bool $bool): string { * @return string representation of datetime */ public static function datetime(\DateTimeImmutable $date): string { - // SQLite does not support timezones. - // The client previously sent the local timezone - // but now it sends UTC time so we need to adjust it here - // to avoid fromDatetime mismatch. - // TODO: Switch to UTC everywhere. - $date = $date->setTimeZone((new \DateTimeImmutable())->getTimeZone()); + // SQLite does not support timezones so we store all dates in UTC. + $utcDate = $date->setTimeZone(new \DateTimeZone('UTC')); - return $date->format('Y-m-d H:i:s'); + return $utcDate->format('Y-m-d H:i:s'); } /** From aaea1ac2c76058bcfb5ef77dc3c4840c32e3c57f Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Thu, 27 Jul 2023 01:40:11 +0200 Subject: [PATCH 3/5] fixup! wip: Use UTC internally --- src/daos/mysql/Items.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/daos/mysql/Items.php b/src/daos/mysql/Items.php index 62a51c90bb..7879ae3520 100644 --- a/src/daos/mysql/Items.php +++ b/src/daos/mysql/Items.php @@ -135,7 +135,7 @@ public function add(array $values): void { :author )', [ - ':datetime' => $values['datetime']->format('Y-m-d H:i:s'), + ':datetime' => static::$stmt::datetime($values['datetime']), ':title' => $values['title']->getRaw(), ':content' => $values['content']->getRaw(), ':thumbnail' => $values['thumbnail'], @@ -395,8 +395,8 @@ public function sync(int $sinceId, DateTimeImmutable $notBefore, DateTimeImmutab $params = [ 'sinceId' => [$sinceId, \PDO::PARAM_INT], 'howMany' => [$howMany, \PDO::PARAM_INT], - 'notBefore' => [$notBefore->format('Y-m-d H:i:s'), \PDO::PARAM_STR], - 'since' => [$since->format('Y-m-d H:i:s'), \PDO::PARAM_STR], + 'notBefore' => [static::$stmt::datetime($notBefore), \PDO::PARAM_STR], + 'since' => [static::$stmt::datetime($since), \PDO::PARAM_STR], ]; return static::$stmt::ensureRowTypes($this->database->exec($query, $params), [ @@ -585,7 +585,7 @@ public function statuses(DateTimeImmutable $since): array { 'SELECT id, unread, starred FROM ' . $this->configuration->dbPrefix . 'items WHERE ' . $this->configuration->dbPrefix . 'items.updatetime > :since;', - [':since' => [$since->format('Y-m-d H:i:s'), \PDO::PARAM_STR]] + [':since' => [static::$stmt::datetime($since), \PDO::PARAM_STR]] ); $res = static::$stmt::ensureRowTypes($res, [ 'id' => DatabaseInterface::PARAM_INT, @@ -651,7 +651,7 @@ public function bulkStatusUpdate(array $statuses): void { // create new status update $sql[$id] = [ 'updates' => [$sk => $statusUpdate['sql']], - 'datetime' => $updateDate->format('Y-m-d H:i:s'), + 'datetime' => static::$stmt::datetime($updateDate), ]; } } From a351a37613dce1d98905fe32313ad466c9c8e747 Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Thu, 27 Jul 2023 01:50:29 +0200 Subject: [PATCH 4/5] fixup! wip: Use UTC internally --- src/daos/StatementsInterface.php | 3 +-- src/daos/mysql/Statements.php | 10 ++++++---- src/daos/pgsql/Statements.php | 10 ++++++++++ src/daos/sqlite/Statements.php | 13 ------------- 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/daos/StatementsInterface.php b/src/daos/StatementsInterface.php index 0fef3648c3..65a83f9ad9 100644 --- a/src/daos/StatementsInterface.php +++ b/src/daos/StatementsInterface.php @@ -90,8 +90,7 @@ public static function rowTouch(string $column): string; public static function bool(bool $bool): string; /** - * Convert a date into a representation suitable for comparison by - * the database engine. + * Convert a date into a representation suitable for storage or comparison. * * @return string representation of datetime */ diff --git a/src/daos/mysql/Statements.php b/src/daos/mysql/Statements.php index 084918f64f..194545f084 100644 --- a/src/daos/mysql/Statements.php +++ b/src/daos/mysql/Statements.php @@ -125,14 +125,16 @@ public static function bool(bool $bool): string { } /** - * Convert a date into a representation suitable for comparison by - * the database engine. + * Convert a date into a representation suitable for storage or comparison. * * @return string representation of datetime */ public static function datetime(\DateTimeImmutable $date): string { - // mysql supports ISO8601 datetime comparisons - return $date->format(\DateTimeImmutable::ATOM); + // SQLite does not support timezones so we store all dates in UTC. + // MySQL supports them for comparison but not for storage. + $utcDate = $date->setTimeZone(new \DateTimeZone('UTC')); + + return $utcDate->format('Y-m-d H:i:s'); } /** diff --git a/src/daos/pgsql/Statements.php b/src/daos/pgsql/Statements.php index b910a7a8bd..46f5954bf6 100644 --- a/src/daos/pgsql/Statements.php +++ b/src/daos/pgsql/Statements.php @@ -73,6 +73,16 @@ public static function csvRowMatches(string $column, string $value): string { return "$value=ANY(string_to_array($column, ','))"; } + /** + * Convert a date into a representation suitable for storage or comparison. + * + * @return string representation of datetime + */ + public static function datetime(\DateTimeImmutable $date): string { + // We are using `TIMESTAMP WITH TIME ZONE` in PostgreSQL, which supports ISO8601 datetime. + return $date->format(\DateTimeImmutable::ATOM); + } + /** * Ensure row values have the appropriate PHP type. This assumes we are * using buffered queries (sql results are in PHP memory). diff --git a/src/daos/sqlite/Statements.php b/src/daos/sqlite/Statements.php index 4bf28f212b..5b2a134488 100644 --- a/src/daos/sqlite/Statements.php +++ b/src/daos/sqlite/Statements.php @@ -43,19 +43,6 @@ public static function bool(bool $bool): string { return $bool ? '1' : '0'; } - /** - * Convert a date into a representation suitable for comparison by - * the database engine. - * - * @return string representation of datetime - */ - public static function datetime(\DateTimeImmutable $date): string { - // SQLite does not support timezones so we store all dates in UTC. - $utcDate = $date->setTimeZone(new \DateTimeZone('UTC')); - - return $utcDate->format('Y-m-d H:i:s'); - } - /** * Match a value to a regular expression. * From d30b23a6653d69c5635378da9d0cd2e7b83ce3bd Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Thu, 27 Jul 2023 01:51:06 +0200 Subject: [PATCH 5/5] fixup! Use immutable DateTime --- src/controllers/Rss.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/controllers/Rss.php b/src/controllers/Rss.php index 2b96ef1b6b..d653d50031 100644 --- a/src/controllers/Rss.php +++ b/src/controllers/Rss.php @@ -75,7 +75,7 @@ public function rss(): void { $newItem->setTitle($this->sanitizeTitle($item['title'] . ' (' . $lastSourceName . ')')); @$newItem->setLink($item['link']); @$newItem->setId($item['link']); - $newItem->setDate(DateTime::createFromImmutable($item['datetime'])); + $newItem->setDate(\DateTime::createFromImmutable($item['datetime'])); $newItem->setDescription(str_replace('"', '"', $item['content'])); // add tags in category node @@ -98,7 +98,7 @@ public function rss(): void { if ($newestEntryDate === null) { $newestEntryDate = new \DateTimeImmutable(); } - $this->feedWriter->setDate(DateTime::createFromImmutable($newestEntryDate)); + $this->feedWriter->setDate(\DateTime::createFromImmutable($newestEntryDate)); $this->feedWriter->printFeed(); }