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