From abf1e23ab5062e143d107199c073ac76882e918e Mon Sep 17 00:00:00 2001
From: Benni Mack <benni@typo3.org>
Date: Fri, 17 Mar 2023 21:40:29 +0100
Subject: [PATCH] [TASK] Use userland strftime() implementation for deprecated
 PHP function
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

This change finally removes all usages to strftime(),
as this function was deprecated in PHP 8.1.
It is recommended to use php-intl to use locale-aware
date formatting.

For places, where no alternative is available
(stdWrap and <f:format.date>),
a polyfill function, which credits to
https://github.com/alphp/strftime
is added to the DateFormatter class.

Kudos to the original autor "BohwaZ" (https://bohwaz.net/)
and the GitHub Repository for allowing us
to use this (MIT license). Open Source rocks!

Resolves: #95872
Releases: main
Change-Id: Ib61170ddc87f12957cbb3d3402fd8eb8b8603a89
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/78161
Reviewed-by: Stefan Bürk <stefan@buerk.tech>
Tested-by: Benni Mack <benni@typo3.org>
Tested-by: Christian Kuhn <lolli@schwarzbu.ch>
Tested-by: core-ci <typo3@b13.com>
Tested-by: Stefan Bürk <stefan@buerk.tech>
Reviewed-by: Christian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Benni Mack <benni@typo3.org>
---
 .../Form/Element/AbstractFormElement.php      |  12 +-
 .../Form/Element/AbstractFormElementTest.php  |   2 +
 .../Classes/Localization/DateFormatter.php    | 192 ++++++++++++++++++
 .../Functional/Persistence/RelationTest.php   |   3 +-
 .../ViewHelpers/Format/DateViewHelper.php     |   5 +-
 .../ContentObject/ContentObjectRenderer.php   |   5 +-
 typo3/sysext/impexp/Classes/Export.php        |  12 +-
 .../DatabaseIntegrityController.php           |  20 +-
 8 files changed, 234 insertions(+), 17 deletions(-)

diff --git a/typo3/sysext/backend/Classes/Form/Element/AbstractFormElement.php b/typo3/sysext/backend/Classes/Form/Element/AbstractFormElement.php
index 8dc920906031..53d2444242c3 100644
--- a/typo3/sysext/backend/Classes/Form/Element/AbstractFormElement.php
+++ b/typo3/sysext/backend/Classes/Form/Element/AbstractFormElement.php
@@ -22,7 +22,10 @@ use TYPO3\CMS\Backend\Form\NodeFactory;
 use TYPO3\CMS\Backend\Utility\BackendUtility;
 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
 use TYPO3\CMS\Core\Imaging\IconFactory;
+use TYPO3\CMS\Core\Localization\DateFormatter;
 use TYPO3\CMS\Core\Localization\LanguageService;
+use TYPO3\CMS\Core\Localization\Locale;
+use TYPO3\CMS\Core\Localization\Locales;
 use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
 use TYPO3\CMS\Core\Utility\ArrayUtility;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -195,8 +198,13 @@ abstract class AbstractFormElement extends AbstractNode
                     $option = isset($formatOptions['option']) ? trim($formatOptions['option']) : '';
                     if ($option) {
                         if (isset($formatOptions['strftime']) && $formatOptions['strftime']) {
-                            // @todo Replace deprecated strftime in php 8.1. Suppress warning in v11.
-                            $value = @strftime($option, (int)$itemValue);
+                            $user = $this->getBackendUser();
+                            if ($user->user['lang'] ?? false) {
+                                $locale = GeneralUtility::makeInstance(Locales::class)->createLocale($user->user['lang']);
+                            } else {
+                                $locale = new Locale();
+                            }
+                            $value = (new DateFormatter())->strftime($option, (int)$itemValue, $locale);
                         } else {
                             $value = date($option, (int)$itemValue);
                         }
diff --git a/typo3/sysext/backend/Tests/Unit/Form/Element/AbstractFormElementTest.php b/typo3/sysext/backend/Tests/Unit/Form/Element/AbstractFormElementTest.php
index ec36959c6575..124ff1e16c02 100644
--- a/typo3/sysext/backend/Tests/Unit/Form/Element/AbstractFormElementTest.php
+++ b/typo3/sysext/backend/Tests/Unit/Form/Element/AbstractFormElementTest.php
@@ -18,6 +18,7 @@ declare(strict_types=1);
 namespace TYPO3\CMS\Backend\Tests\Unit\Form\Element;
 
 use TYPO3\CMS\Backend\Form\Element\AbstractFormElement;
+use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
 use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
 
 class AbstractFormElementTest extends UnitTestCase
@@ -270,6 +271,7 @@ class AbstractFormElementTest extends UnitTestCase
      */
     public function formatValueWithGivenConfiguration(array $config, ?string $itemValue, string $expectedResult): void
     {
+        $GLOBALS['BE_USER'] = new BackendUserAuthentication();
         $subject = $this->getAccessibleMock(AbstractFormElement::class, ['render'], [], '', false);
         $timezoneBackup = date_default_timezone_get();
         date_default_timezone_set('UTC');
diff --git a/typo3/sysext/core/Classes/Localization/DateFormatter.php b/typo3/sysext/core/Classes/Localization/DateFormatter.php
index a48268e6b6cf..be6fa5f72dd3 100644
--- a/typo3/sysext/core/Classes/Localization/DateFormatter.php
+++ b/typo3/sysext/core/Classes/Localization/DateFormatter.php
@@ -58,4 +58,196 @@ class DateFormatter
         }
         return $dateFormatter->format($date) ?: '';
     }
+
+    /**
+     * Locale-formatted strftime using IntlDateFormatter (PHP 8.1 compatible)
+     * This provides a cross-platform alternative to strftime() for when it will be removed from PHP.
+     * Note that output can be slightly different between libc sprintf and this function as it is using ICU.
+     *
+     * Original author BohwaZ <https://bohwaz.net/>
+     * Adapted from https://github.com/alphp/strftime
+     * MIT licensed
+     */
+    public function strftime(string $format, int|string|\DateTimeInterface|null $timestamp, string|Locale|null $locale = null, $useUtcTimeZone = false): string
+    {
+        if (!($timestamp instanceof \DateTimeInterface)) {
+            $timestamp = is_int($timestamp) ? '@' . $timestamp : (string)$timestamp;
+            try {
+                $timestamp = new \DateTime($timestamp);
+            } catch (\Exception $e) {
+                throw new \InvalidArgumentException('$timestamp argument is neither a valid UNIX timestamp, a valid date-time string or a DateTime object.', 1679091446, $e);
+            }
+            $timestamp->setTimezone(new \DateTimeZone($useUtcTimeZone ? 'UTC' : date_default_timezone_get()));
+        }
+
+        if (empty($locale)) {
+            // get current locale
+            $locale = setlocale(LC_TIME, '0');
+        } else {
+            $locale = (string)$locale;
+        }
+        // remove trailing part not supported by ext-intl locale
+        $locale = preg_replace('/[^\w-].*$/', '', $locale);
+
+        $intl_formats = [
+            '%a' => 'EEE',	// An abbreviated textual representation of the day	Sun through Sat
+            '%A' => 'EEEE',	// A full textual representation of the day	Sunday through Saturday
+            '%b' => 'MMM',	// Abbreviated month name, based on the locale	Jan through Dec
+            '%B' => 'MMMM',	// Full month name, based on the locale	January through December
+            '%h' => 'MMM',	// Abbreviated month name, based on the locale (an alias of %b)	Jan through Dec
+        ];
+
+        $intl_formatter = function (\DateTimeInterface $timestamp, string $format) use ($intl_formats, $locale): string {
+            $tz = $timestamp->getTimezone();
+            $date_type = \IntlDateFormatter::FULL;
+            $time_type = \IntlDateFormatter::FULL;
+            $pattern = '';
+
+            switch ($format) {
+                // %c = Preferred date and time stamp based on locale
+                // Example: Tue Feb 5 00:45:10 2009 for February 5, 2009 at 12:45:10 AM
+                case '%c':
+                    $date_type = \IntlDateFormatter::LONG;
+                    $time_type = \IntlDateFormatter::SHORT;
+                    break;
+
+                    // %x = Preferred date representation based on locale, without the time
+                    // Example: 02/05/09 for February 5, 2009
+                case '%x':
+                    $date_type = \IntlDateFormatter::SHORT;
+                    $time_type = \IntlDateFormatter::NONE;
+                    break;
+
+                    // Localized time format
+                case '%X':
+                    $date_type = \IntlDateFormatter::NONE;
+                    $time_type = \IntlDateFormatter::MEDIUM;
+                    break;
+
+                default:
+                    $pattern = $intl_formats[$format];
+            }
+
+            // In October 1582, the Gregorian calendar replaced the Julian in much of Europe, and
+            //  the 4th October was followed by the 15th October.
+            // ICU (including IntlDateFormattter) interprets and formats dates based on this cutover.
+            // Posix (including strftime) and timelib (including DateTimeImmutable) instead use
+            //  a "proleptic Gregorian calendar" - they pretend the Gregorian calendar has existed forever.
+            // This leads to the same instants in time, as expressed in Unix time, having different representations
+            //  in formatted strings.
+            // To adjust for this, a custom calendar can be supplied with a cutover date arbitrarily far in the past.
+            $calendar = \IntlGregorianCalendar::createInstance();
+            $calendar->setGregorianChange(PHP_INT_MIN);
+
+            return (new \IntlDateFormatter($locale, $date_type, $time_type, $tz, $calendar, $pattern))->format($timestamp) ?: '';
+        };
+
+        // Same order as https://www.php.net/manual/en/function.strftime.php
+        $translation_table = [
+            // Day
+            '%a' => $intl_formatter,
+            '%A' => $intl_formatter,
+            '%d' => 'd',
+            '%e' => function (\DateTimeInterface $timestamp, string $_): string {
+                return sprintf('% 2u', $timestamp->format('j'));
+            },
+            '%j' => function (\DateTimeInterface $timestamp, string $_): string {
+                // Day number in year, 001 to 366
+                return sprintf('%03d', (int)($timestamp->format('z'))+1);
+            },
+            '%u' => 'N',
+            '%w' => 'w',
+
+            // Week
+            '%U' => function (\DateTimeInterface $timestamp, string $_): string {
+                // Number of weeks between date and first Sunday of year
+                $day = new \DateTime(sprintf('%d-01 Sunday', $timestamp->format('Y')));
+                return sprintf('%02u', 1 + ($timestamp->format('z') - $day->format('z')) / 7);
+            },
+            '%V' => 'W',
+            '%W' => function (\DateTimeInterface $timestamp, string $_): string {
+                // Number of weeks between date and first Monday of year
+                $day = new \DateTime(sprintf('%d-01 Monday', $timestamp->format('Y')));
+                return sprintf('%02u', 1 + ($timestamp->format('z') - $day->format('z')) / 7);
+            },
+
+            // Month
+            '%b' => $intl_formatter,
+            '%B' => $intl_formatter,
+            '%h' => $intl_formatter,
+            '%m' => 'm',
+
+            // Year
+            '%C' => function (\DateTimeInterface $timestamp, string $_): string {
+                // Century (-1): 19 for 20th century
+                return (string)floor($timestamp->format('Y') / 100);
+            },
+            '%g' => function (\DateTimeInterface $timestamp, string $_): string {
+                return substr($timestamp->format('o'), -2);
+            },
+            '%G' => 'o',
+            '%y' => 'y',
+            '%Y' => 'Y',
+
+            // Time
+            '%H' => 'H',
+            '%k' => function (\DateTimeInterface $timestamp, string $_): string {
+                return sprintf('% 2u', $timestamp->format('G'));
+            },
+            '%I' => 'h',
+            '%l' => function (\DateTimeInterface $timestamp, string $_): string {
+                return sprintf('% 2u', $timestamp->format('g'));
+            },
+            '%M' => 'i',
+            '%p' => 'A', // AM PM (this is reversed on purpose!)
+            '%P' => 'a', // am pm
+            '%r' => 'h:i:s A', // %I:%M:%S %p
+            '%R' => 'H:i', // %H:%M
+            '%S' => 's',
+            '%T' => 'H:i:s', // %H:%M:%S
+            '%X' => $intl_formatter, // Preferred time representation based on locale, without the date
+
+            // Timezone
+            '%z' => 'O',
+            '%Z' => 'T',
+
+            // Time and Date Stamps
+            '%c' => $intl_formatter,
+            '%D' => 'm/d/Y',
+            '%F' => 'Y-m-d',
+            '%s' => 'U',
+            '%x' => $intl_formatter,
+        ];
+
+        $out = preg_replace_callback('/(?<!%)%([_#-]?)([a-zA-Z])/', function ($match) use ($translation_table, $timestamp) {
+            $prefix = $match[1];
+            $char = $match[2];
+            $pattern = '%' . $char;
+            if ($pattern == '%n') {
+                return "\n";
+            }
+            if ($pattern == '%t') {
+                return "\t";
+            }
+
+            if (!isset($translation_table[$pattern])) {
+                throw new \InvalidArgumentException(sprintf('Format "%s" is unknown in time format', $pattern), 1679091475);
+            }
+
+            $replace = $translation_table[$pattern];
+
+            if (is_string($replace)) {
+                $result = $timestamp->format($replace);
+            } else {
+                $result = $replace($timestamp, $pattern);
+            }
+
+            return match ($prefix) {
+                '_' => preg_replace('/\G0(?=.)/', ' ', $result),
+                '#', '-' => preg_replace('/^0+(?=.)/', '', $result),
+                default => $result,
+            };
+        }, $format);
+        return str_replace('%%', '%', $out);
+    }
 }
diff --git a/typo3/sysext/extbase/Tests/Functional/Persistence/RelationTest.php b/typo3/sysext/extbase/Tests/Functional/Persistence/RelationTest.php
index a02f23e4b33d..c0ac612e3661 100644
--- a/typo3/sysext/extbase/Tests/Functional/Persistence/RelationTest.php
+++ b/typo3/sysext/extbase/Tests/Functional/Persistence/RelationTest.php
@@ -555,8 +555,7 @@ class RelationTest extends FunctionalTestCase
         $tags = clone $post->getTags();
         $post->setTags(new ObjectStorage());
 
-        // @todo Replace deprecated strftime in php 8.1. Suppress warning in v11.
-        $newTag = new Tag('INSERTED TAG at position 6 : ' . @strftime(''));
+        $newTag = new Tag('INSERTED TAG at position 6 : ');
 
         $counter = 1;
         foreach ($tags as $tag) {
diff --git a/typo3/sysext/fluid/Classes/ViewHelpers/Format/DateViewHelper.php b/typo3/sysext/fluid/Classes/ViewHelpers/Format/DateViewHelper.php
index 462baa0bbb3c..3fc3fdd852ad 100644
--- a/typo3/sysext/fluid/Classes/ViewHelpers/Format/DateViewHelper.php
+++ b/typo3/sysext/fluid/Classes/ViewHelpers/Format/DateViewHelper.php
@@ -195,8 +195,9 @@ final class DateViewHelper extends AbstractViewHelper
             return (new DateFormatter())->format($date, $pattern, $locale);
         }
         if (str_contains($format, '%')) {
-            // @todo Replace deprecated strftime in php 8.1. Suppress warning in v11.
-            return @strftime($format, (int)$date->format('U'));
+            // @todo: deprecate this syntax in TYPO3 v13.
+            $locale = $arguments['locale'] ?? self::resolveLocale($renderingContext);
+            return (new DateFormatter())->format($date, $format, $locale);
         }
         return $date->format($format);
     }
diff --git a/typo3/sysext/frontend/Classes/ContentObject/ContentObjectRenderer.php b/typo3/sysext/frontend/Classes/ContentObject/ContentObjectRenderer.php
index 8d64ec2f4dec..abb029fd17aa 100644
--- a/typo3/sysext/frontend/Classes/ContentObject/ContentObjectRenderer.php
+++ b/typo3/sysext/frontend/Classes/ContentObject/ContentObjectRenderer.php
@@ -1871,15 +1871,14 @@ class ContentObjectRenderer implements LoggerAwareInterface
      * @param string $content Input value undergoing processing in this function.
      * @param array $conf stdWrap properties for strftime.
      * @return string The processed input value
-     * @todo @todo Replace deprecated strftime/gmstrftime and whole method in php 8.1. Suppress warning in v11.
      */
     public function stdWrap_strftime($content = '', $conf = [])
     {
         // Check for zero length string to mimic default case of strtime/gmstrftime
         $content = (string)$content === '' ? $GLOBALS['EXEC_TIME'] : (int)$content;
         $content = (isset($conf['strftime.']['GMT']) && $conf['strftime.']['GMT'])
-            ? @gmstrftime($conf['strftime'] ?? null, $content)
-            : @strftime($conf['strftime'] ?? null, $content);
+            ? (new DateFormatter())->strftime($conf['strftime'] ?? '', $content, null, true)
+            : (new DateFormatter())->strftime($conf['strftime'] ?? '', $content);
         if (!empty($conf['strftime.']['charset'])) {
             $output = mb_convert_encoding((string)$content, 'utf-8', trim(strtolower($conf['strftime.']['charset'])));
             return $output ?: $content;
diff --git a/typo3/sysext/impexp/Classes/Export.php b/typo3/sysext/impexp/Classes/Export.php
index 973413c2e7d3..2f518959ef40 100644
--- a/typo3/sysext/impexp/Classes/Export.php
+++ b/typo3/sysext/impexp/Classes/Export.php
@@ -29,6 +29,9 @@ use TYPO3\CMS\Core\Database\ReferenceIndex;
 use TYPO3\CMS\Core\Exception;
 use TYPO3\CMS\Core\Html\HtmlParser;
 use TYPO3\CMS\Core\Information\Typo3Version;
+use TYPO3\CMS\Core\Localization\DateFormatter;
+use TYPO3\CMS\Core\Localization\Locale;
+use TYPO3\CMS\Core\Localization\Locales;
 use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderWritePermissionsException;
 use TYPO3\CMS\Core\Resource\File;
 use TYPO3\CMS\Core\Resource\Folder;
@@ -314,6 +317,12 @@ class Export extends ImportExport
      */
     protected function setMetaData(): void
     {
+        $user = $this->getBackendUser();
+        if ($user->user['lang'] ?? false) {
+            $locale = GeneralUtility::makeInstance(Locales::class)->createLocale($user->user['lang']);
+        } else {
+            $locale = new Locale();
+        }
         $this->dat['header']['meta'] = [
             'title' => $this->title,
             'description' => $this->description,
@@ -322,8 +331,7 @@ class Export extends ImportExport
             'packager_name' => $this->getBackendUser()->user['realName'],
             'packager_email' => $this->getBackendUser()->user['email'],
             'TYPO3_version' => (string)GeneralUtility::makeInstance(Typo3Version::class),
-            // @todo Replace deprecated strftime in php 8.1. Suppress warning in v11.
-            'created' => @strftime('%A %e. %B %Y', $GLOBALS['EXEC_TIME']),
+            'created' => (new DateFormatter())->format($GLOBALS['EXEC_TIME'], 'EEE d. MMMM y', $locale),
         ];
     }
 
diff --git a/typo3/sysext/lowlevel/Classes/Controller/DatabaseIntegrityController.php b/typo3/sysext/lowlevel/Classes/Controller/DatabaseIntegrityController.php
index fe1014e76377..34cf2e48a89a 100644
--- a/typo3/sysext/lowlevel/Classes/Controller/DatabaseIntegrityController.php
+++ b/typo3/sysext/lowlevel/Classes/Controller/DatabaseIntegrityController.php
@@ -38,7 +38,10 @@ use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
 use TYPO3\CMS\Core\Database\ReferenceIndex;
 use TYPO3\CMS\Core\Imaging\Icon;
 use TYPO3\CMS\Core\Imaging\IconFactory;
+use TYPO3\CMS\Core\Localization\DateFormatter;
 use TYPO3\CMS\Core\Localization\LanguageService;
+use TYPO3\CMS\Core\Localization\Locale;
+use TYPO3\CMS\Core\Localization\Locales;
 use TYPO3\CMS\Core\Messaging\FlashMessage;
 use TYPO3\CMS\Core\Messaging\FlashMessageRendererResolver;
 use TYPO3\CMS\Core\Messaging\FlashMessageService;
@@ -861,6 +864,12 @@ class DatabaseIntegrityController
     {
         $out = '';
         $fields = [];
+        $user = $this->getBackendUserAuthentication();
+        if ($user->user['lang'] ?? false) {
+            $locale = GeneralUtility::makeInstance(Locales::class)->createLocale($user->user['lang']);
+        } else {
+            $locale = new Locale();
+        }
         // Analysing the fields in the table.
         if (is_array($GLOBALS['TCA'][$table] ?? null)) {
             $fC = $GLOBALS['TCA'][$table]['columns'][$fieldName] ?? null;
@@ -945,18 +954,17 @@ class DatabaseIntegrityController
         switch ($fields['type']) {
             case 'date':
                 if ($fieldValue != -1) {
-                    // @todo Replace deprecated strftime in php 8.1. Suppress warning in v11.
-                    $out = (string)@strftime('%d-%m-%Y', (int)$fieldValue);
+                    $formatter = new DateFormatter();
+                    $out = $formatter->format((int)$fieldValue, 'SHORTDATE', $locale);
                 }
                 break;
             case 'time':
                 if ($fieldValue != -1) {
+                    $formatter = new DateFormatter();
                     if ($splitString === '<br />') {
-                        // @todo Replace deprecated strftime in php 8.1. Suppress warning in v11.
-                        $out = (string)@strftime('%H:%M' . $splitString . '%d-%m-%Y', (int)$fieldValue);
+                        $out = $formatter->format((int)$fieldValue, 'HH:mm\'' . $splitString . '\'dd-MM-yyyy', $locale);
                     } else {
-                        // @todo Replace deprecated strftime in php 8.1. Suppress warning in v11.
-                        $out = (string)@strftime('%H:%M %d-%m-%Y', (int)$fieldValue);
+                        $out = $formatter->format((int)$fieldValue, 'HH:mm dd-MM-yyyy', $locale);
                     }
                 }
                 break;
-- 
GitLab