Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
23.21% covered (danger)
23.21%
13 / 56
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserTimeCorrection
23.21% covered (danger)
23.21%
13 / 56
60.00% covered (warning)
60.00%
6 / 10
466.08
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getCorrectionType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTimeOffset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTimeOffsetInterval
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 getTimeZone
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isValid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parse
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
240
 formatTimezoneOffset
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 toString
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
42
 __toString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\User;
8
9use DateInterval;
10use DateTime;
11use DateTimeZone;
12use Exception;
13use MediaWiki\Utils\MWTimestamp;
14use Stringable;
15use Wikimedia\RequestTimeout\TimeoutException;
16
17/**
18 * Utility class to parse the TimeCorrection string value.
19 *
20 * These values are used to specify the time offset for a user and are stored in
21 * the database as a user preference and returned by the preferences APIs
22 *
23 * The class will correct invalid input and adjusts timezone offsets to applicable dates,
24 * taking into account DST etc.
25 *
26 * @since 1.37
27 * @ingroup User
28 * @author Derk-Jan Hartman <hartman.wiki@gmail.com>
29 */
30class UserTimeCorrection implements Stringable {
31
32    /**
33     * @var string (default) Time correction based on the MediaWiki's system offset from UTC.
34     * The System offset can be configured with wgLocalTimezone and/or wgLocalTZoffset
35     */
36    public const SYSTEM = 'System';
37
38    /** @var string Time correction based on a user defined offset from UTC */
39    public const OFFSET = 'Offset';
40
41    /** @var string Time correction based on a user defined timezone */
42    public const ZONEINFO = 'ZoneInfo';
43
44    private readonly DateTime $date;
45
46    private bool $valid;
47
48    /** @var string */
49    private $correctionType;
50
51    /** @var int Offset in minutes */
52    private $offset;
53
54    /** @var DateTimeZone|null */
55    private $timeZone;
56
57    /**
58     * @param string $timeCorrection Original time correction string
59     * @param DateTime|null $relativeToDate The date used to calculate the time zone offset of.
60     *            This defaults to the current date and time.
61     * @param int $systemOffset Offset for self::SYSTEM in minutes
62     */
63    public function __construct(
64        string $timeCorrection,
65        ?DateTime $relativeToDate = null,
66        int $systemOffset = 0,
67    ) {
68        $this->date = $relativeToDate ?? new DateTime( '@' . MWTimestamp::time() );
69        $this->valid = false;
70        $this->parse( $timeCorrection, $systemOffset );
71    }
72
73    /**
74     * Get time offset for a user
75     *
76     * @return string Offset that was applied to the user
77     */
78    public function getCorrectionType(): string {
79        return $this->correctionType;
80    }
81
82    /**
83     * Get corresponding time offset for this correction
84     * Note: When correcting dates/times, apply only the offset OR the time zone, not both.
85     * @return int Offset in minutes
86     */
87    public function getTimeOffset(): int {
88        return $this->offset;
89    }
90
91    /**
92     * Get corresponding time offset for this correction
93     * Note: When correcting dates/times, apply only the offset OR the time zone, not both.
94     * @return DateInterval Offset in minutes as a DateInterval
95     */
96    public function getTimeOffsetInterval(): DateInterval {
97        $offset = abs( $this->offset );
98        $interval = new DateInterval( "PT{$offset}M" );
99        if ( $this->offset < 1 ) {
100            $interval->invert = 1;
101        }
102        return $interval;
103    }
104
105    /**
106     * The time zone if known
107     * Note: When correcting dates/times, apply only the offset OR the time zone, not both.
108     * @return DateTimeZone|null
109     */
110    public function getTimeZone(): ?DateTimeZone {
111        return $this->timeZone;
112    }
113
114    /**
115     * Was the original correction specification valid
116     */
117    public function isValid(): bool {
118        return $this->valid;
119    }
120
121    /**
122     * Parse the timecorrection string as stored in the database for a user
123     * or as entered into the Preferences form field
124     *
125     * There can be two forms of these strings:
126     * 1. A pipe separated tuple of a maximum of 3 fields
127     *    - Field 1 is the type of offset definition
128     *    - Field 2 is the offset in minutes from UTC (ignored for System type)
129     *      FIXME Since it's ignored, remove the offset from System everywhere.
130     *    - Field 3 is a timezone identifier from the tz database (only required for ZoneInfo type)
131     *    - The offset for a ZoneInfo type is unreliable because of DST.
132     *      After retrieving it from the database, it should be recalculated based on the TZ identifier.
133     *    Examples:
134     *    - System
135     *    - System|60
136     *    - Offset|60
137     *    - ZoneInfo|60|Europe/Amsterdam
138     *
139     * 2. The following form provides an offset in hours and minutes
140     *    This currently should only be used by the preferences input field,
141     *    but historically they were present in the database.
142     *    TODO: write a maintenance script to migrate these old db values
143     *    Examples:
144     *    - 16:00
145     *    - 10
146     *
147     * @param string $timeCorrection
148     * @param int $systemOffset
149     */
150    private function parse( string $timeCorrection, int $systemOffset ) {
151        $data = explode( '|', $timeCorrection, 3 );
152
153        // First handle the case of an actual timezone being specified.
154        if ( $data[0] === self::ZONEINFO && isset( $data[2] ) ) {
155            try {
156                $this->correctionType = self::ZONEINFO;
157                $this->timeZone = new DateTimeZone( $data[2] );
158                $this->offset = (int)floor( $this->timeZone->getOffset( $this->date ) / 60 );
159                $this->valid = true;
160                return;
161            } catch ( TimeoutException $e ) {
162                throw $e;
163            } catch ( Exception ) {
164                // Not a valid/known timezone.
165                // Fall back to any specified offset
166            }
167        }
168
169        // If $timeCorrection is in fact a pipe-separated value, check the
170        // first value.
171        switch ( $data[0] ) {
172            case self::OFFSET:
173            case self::ZONEINFO:
174                $this->correctionType = self::OFFSET;
175                // First value is Offset, so use the specified offset
176                $this->offset = (int)( $data[1] ?? 0 );
177                // If this is ZoneInfo, then we didn't recognize the TimeZone
178                $this->valid = isset( $data[1] ) && $data[0] === self::OFFSET;
179                break;
180            case self::SYSTEM:
181                $this->correctionType = self::SYSTEM;
182                $this->offset = $systemOffset;
183                $this->valid = true;
184                break;
185            default:
186                // $timeCorrection actually isn't a pipe separated value, but instead
187                // a colon separated value. This is only used by the HTMLTimezoneField userinput
188                // but can also still be present in the Db. (but shouldn't be)
189                $this->correctionType = self::OFFSET;
190                $data = explode( ':', $timeCorrection, 2 );
191                if ( count( $data ) >= 2 ) {
192                    // Combination hours and minutes.
193                    $this->offset = abs( (int)$data[0] ) * 60 + (int)$data[1];
194                    if ( (int)$data[0] < 0 ) {
195                        $this->offset *= -1;
196                    }
197                    $this->valid = true;
198                } elseif ( preg_match( '/^[+-]?\d+$/', $data[0] ) ) {
199                    // Just hours.
200                    $this->offset = (int)$data[0] * 60;
201                    $this->valid = true;
202                } else {
203                    // We really don't know this. Fallback to System
204                    $this->correctionType = self::SYSTEM;
205                    $this->offset = $systemOffset;
206                    return;
207                }
208                break;
209        }
210
211        // Max is +14:00 and min is -12:00, see:
212        // https://en.wikipedia.org/wiki/Timezone
213        if ( $this->offset < -12 * 60 || $this->offset > 14 * 60 ) {
214            $this->valid = false;
215        }
216        // 14:00
217        $this->offset = min( $this->offset, 14 * 60 );
218        // -12:00
219        $this->offset = max( $this->offset, -12 * 60 );
220    }
221
222    /**
223     * Converts a timezone offset in minutes (e.g., "120") to an hh:mm string like "+02:00".
224     * @param int $offset
225     * @return string
226     */
227    public static function formatTimezoneOffset( int $offset ): string {
228        $hours = $offset > 0 ? floor( $offset / 60 ) : ceil( $offset / 60 );
229        return sprintf( '%+03d:%02d', $hours, abs( $offset ) % 60 );
230    }
231
232    /**
233     * Note: The string value of this object might not be equal to the original value
234     * @return string a timecorrection string representing this value
235     */
236    public function toString(): string {
237        switch ( $this->correctionType ) {
238            case self::ZONEINFO:
239                if ( $this->timeZone ) {
240                    return "ZoneInfo|{$this->offset}|{$this->timeZone->getName()}";
241                }
242                // If not, fallback:
243            case self::SYSTEM:
244            case self::OFFSET:
245            default:
246                return "{$this->correctionType}|{$this->offset}";
247        }
248    }
249
250    public function __toString() {
251        return $this->toString();
252    }
253}