Files
mrbs-equbao-2026/lib/MRBS/ICalendar/Property.php
T
人事系统开发 1ba6efd8ed MRBS 1.12.2 等保2.0二级整改完整提交
包含:登录失败锁定、90天密码有效期、30分钟会话超时、
强制改密、登录审计日志、屏幕水印、企业背景图、
备案信息固定底部、favicon、JS空集合保护、
会话过期体验优化(403 JSON)、display_errors 关闭、
固定 key 根治 Integrity check failed 等全部改动

注意:config.inc.php/.htaccess/.user.ini 含敏感信息,
通过 .gitignore 排除,勿推送到公开仓库。
2026-09-09 16:55:02 +08:00

490 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace MRBS\ICalendar;
use DateTimeZone;
use MRBS\DateTime;
use MRBS\Utf8\Utf8String;
class Property
{
private const DATETIME_FORMAT = 'Ymd\THis'; // Format for expressing iCalendar dates
private const VALUE_TYPE_BINARY = 'BINARY';
private const VALUE_TYPE_BOOLEAN = 'BOOLEAN';
private const VALUE_TYPE_CAL_ADDRESS = 'CAL-ADDRESS';
private const VALUE_TYPE_DATE = 'DATE';
private const VALUE_TYPE_DATE_TIME = 'DATE-TIME';
private const VALUE_TYPE_DURATION = 'DURATION';
private const VALUE_TYPE_FLOAT = 'FLOAT';
private const VALUE_TYPE_INTEGER = 'INTEGER';
private const VALUE_TYPE_PERIOD = 'PERIOD';
private const VALUE_TYPE_RECUR = 'RECUR';
private const VALUE_TYPE_TEXT = 'TEXT';
private const VALUE_TYPE_TIME = 'TIME';
private const VALUE_TYPE_URI = 'URI';
private const VALUE_TYPE_UTC_OFFSET = 'UTC-OFFSET';
private $name;
private $params = [];
private $values = [];
private $value_type;
// TODO: Rewrite import.php to use these classes.
/**
* @param string|string[] $values
*/
public function __construct(string $name, $values)
{
// Property names are case-insensitive, but by convention we use uppercase.
$this->name = mb_strtoupper($name);
$this->values = (array) $values;
$this->setImplicitValueType();
}
/**
* Create a property instance given a string.
*
* @param string $string An unfolded property line
*/
public static function createFromString(string $string) : self
{
$parsed_string = self::parseLine($string);
$property = new self($parsed_string['name'], $parsed_string['values']);
foreach ($parsed_string['params'] as $name => $values)
{
$property->addParameter($name, $values);
}
return $property;
}
/**
* Create a property instance of type DATE-TIME from UNIX timestamps.
*
* @param int|int[] $timestamps
*/
public static function createFromTimestamps(string $name, $timestamps, ?string $tzid=null) : self
{
$result = new self($name, self::convertTimestamps($timestamps, $tzid));
if (isset($tzid))
{
$result->addParameter('TZID', $tzid);
}
return $result;
}
/**
* Convert a DATE-TIME value to a UNIX timestamp.
*/
public static function convertDatetimeValue(string $value, ?string $tzid=null) : int
{
if (!isset($tzid))
{
// FORM #1: DATE WITH LOCAL TIME
if (!str_ends_with($value, 'Z'))
{
throw new \Exception("Floating times not supported");
}
// FORM #2: DATE WITH UTC TIME
$value = rtrim($value, 'Z');
$tzid = 'UTC';
}
else
{
if (str_ends_with($value, 'Z'))
{
throw new \Exception("Both a TZID parameter and a Z suffix are not supported (see RFC 5545 section 3.3.5");
}
// FORM #3: DATE WITH LOCAL TIME AND TIME ZONE REFERENCE
}
$datetime = DateTime::createFromFormat('Ymd\THis', $value, new DateTimeZone($tzid));
return $datetime->getTimestamp();
}
/**
* Convert an array of UNIX timestamps to DATE-TIME values.
*
* @param int|int[] $timestamps
* @return string[]
*/
public static function convertTimestamps($timestamps, ?string $tzid=null) : array
{
$values = [];
$timestamps = (array) $timestamps;
$format = self::DATETIME_FORMAT;
if (!isset($tzid))
{
$tzid = 'UTC';
$format .= '\Z';
}
foreach ($timestamps as $timestamp)
{
$date = new DateTime('now', new DateTimeZone($tzid));
$date->setTimestamp($timestamp);
$values[] = $date->format($format);
}
return $values;
}
/**
* Adds a parameter/parameters to the property.
*
* @param string|string[] $values
*/
public function addParameter(string $name, $values) : void
{
// Parameter names are case-insensitive, but by convention we use uppercase.
// Parameters can have multiple values [param = param-name "=" param-value *("," param-value)].
// See, for example, DELEGATED-FROM and DELEGATED-TO in RFC 5545.
$uc_name = mb_strtoupper($name);
$this->params[$uc_name] = array_merge($this->params[$uc_name] ?? [], (array) $values);
// If the value type has been set explicitly using a VALUE parameter then update the value type.
if ($uc_name == 'VALUE')
{
// The VALUE parameter can only have one value.
$this->value_type = mb_strtoupper($values[0]);
}
}
/*
* Get the property name.
*/
public function getName() : string
{
return $this->name;
}
/**
* Get the property values.
*/
public function getValues() : array
{
return $this->values;
}
public function getParamValues(string $name) : array
{
return $this->params[$name] ?? [];
}
/**
* Convert the property to an unfolded string.
*/
public function toString() : string
{
$result = $this->name;
foreach ($this->params as $name => $values)
{
$result .= ';' . $name . '=' . implode(',', array_map([self::class, 'escapeParamValue'], $values));
}
if ($this->value_type == self::VALUE_TYPE_TEXT)
{
$value_string = implode(',', array_map([self::class, 'escapeText'], $this->values));
}
else
{
$value_string = implode(',', $this->values);
}
return "$result:$value_string" . Calendar::EOL;
}
/**
* Converts a property of value type DATE-TIME to UNIX timestamps.
*
* @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.5
*
* @return int[]
*/
public function toTimestamps() : array
{
if ($this->value_type !== self::VALUE_TYPE_DATE_TIME)
{
throw new \Exception("Property '$this->name' is not of type DATE-TIME");
}
$result = [];
$tzid = (isset($this->params['TZID'])) ? $this->params['TZID'][0] : null;
foreach ($this->values as $value)
{
$result[] = self::convertDatetimeValue($value, $tzid);
}
return $result;
}
/**
* Parse a property content line
*
* @param string $line An unfolded property line
* @return array{'name': string, 'params': array<string, string[]>, 'values': string[]}
*/
private static function parseLine(string $line) : array
{
$result = [];
$params = [];
// Get the property name, which will be the part before the first colon or semicolon.
$split = preg_split('/([:;])/', $line, 2, PREG_SPLIT_DELIM_CAPTURE);
$result['name'] = $split[0];
// Get any parameters, which come after a semicolon that isn't in a double-quoted string.
while ($split[1] == ';')
{
$split = preg_split('/([:;](?![^"]*"{1}[:;]))/', $split[2], 2, PREG_SPLIT_DELIM_CAPTURE);
$param = self::parseParam($split[0]);
$params[$param['name']] = $param['values'];
}
$result['params'] = $params;
// Finally, get the property values, which come after a colon that isn't in a double-quoted string.
$result['values'] = self::parsePropertyValues($split[2]);
return $result;
}
private static function parseParam(string $param) : array
{
$result = [];
$split = preg_split('/(=)/', $param, 2, PREG_SPLIT_DELIM_CAPTURE);
$result['name'] = $split[0];
$result['values'] = self::parseParamValues($split[2]);
return $result;
}
/**
* Parse a parameter value string.
*/
private static function parseParamValues(string $value_string) : array
{
// Property parameters can have multiple values (see https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.4).
// Split the sting by unescaped commas.
$result = preg_split('/(,(?![^"]*"{1},))/', $value_string);
// Unescape the values
return array_map([self::class, 'unescapeParamValue'], $result);
}
/**
* Parse a property value string.
*/
private static function parsePropertyValues(string $value_string) : array
{
// Properties can have multiple values, separated by commas. These are difficult
// to parse using a regex as look-behinds need to be fixed width, so we can't look
// for an odd or even number of backslashes. Instead, we just iterate through the
// characters in the string.
$result = [];
$value = '';
$in_escape = false;
$iterator = new Utf8String($value_string);
while (null !== ($current_char = $iterator->current()))
{
if ($in_escape)
{
if (!in_array($current_char, ["\\", ";", ",", "\n", "\N"]))
{
$message = "Invalid escape sequence '\\$current_char' in value string '$value_string'.";
trigger_error($message, E_USER_WARNING);
}
$value .= $current_char;
$in_escape = false;
}
elseif ($current_char == "\\")
{
$in_escape = true;
}
elseif ($current_char == ",")
{
$result[] = $value;
$value = '';
}
else
{
$value .= $current_char;
}
$iterator->next();
}
$result[] = $value;
return $result;
}
/**
* Escapes a parameter value, if necessary, so that it can be used in an iCalendar property.
*/
private static function escapeParamValue(string $value) : string
{
// From RFC 5545:
// quoted-string = DQUOTE *QSAFE-CHAR DQUOTE
// QSAFE-CHAR = WSP / %x21 / %x23-7E / NON-US-ASCII
// ; Any character except CONTROL and DQUOTE
// "Property parameter values MUST NOT contain the DQUOTE character. The
// DQUOTE character is used as a delimiter for parameter values that
// contain restricted characters or URI text."
if (str_contains($value, '"'))
{
$value = str_replace('"', "'", $value);
$message = "Parameter value '$value' contains double quotes. This is not allowed. They have been replaced with single quotes.";
trigger_error($message, E_USER_WARNING);
}
// "Property parameter values that contain the COLON, SEMICOLON, or COMMA
// character separators MUST be specified as quoted-string text values."
if (preg_match('/[:;,]/', $value))
{
$value = '"' . $value . '"';
}
return $value;
}
/**
* Unescape a parameter value.
*/
private static function unescapeParamValue(string $str) : string
{
return trim($str, '"');
}
/**
* Escape text for use in a property TEXT value.
*/
private static function escapeText(string $text) : string
{
// Escape '\'
$text = str_replace("\\", "\\\\", $text);
// Escape ';'
$text = str_replace(";", "\;", $text);
// Escape ','
$text = str_replace(",", "\,", $text);
// EOL can only be \n
$text = str_replace("\r\n", "\n", $text);
// Escape '\n'
$text = str_replace("\n", "\\n", $text);
// Escape '\N'
$text = str_replace("\N", "\\N", $text);
return $text;
}
/**
* Reverses RFC 5545 escaping of text.
*
* Only suitable for a single value, not a list of values.
*/
private static function unescapeText(string $str) : string
{
// Unescape '\N'
$str = str_replace("\\N", "\N", $str);
// Unescape '\n'
$str = str_replace("\\n", "\n", $str);
// Unescape ','
$str = str_replace("\,", ",", $str);
// Unescape ';'
$str = str_replace("\;", ";", $str);
// Unescape '\'
$str = str_replace("\\\\", "\\", $str);
return $str;
}
private function setImplicitValueType() : void
{
// See RFC 5545 for the default value types for each property.
switch ($this->name)
{
case 'ATTENDEE':
case 'ORGANIZER':
$this->value_type = self::VALUE_TYPE_CAL_ADDRESS;
break;
case 'COMPLETED':
case 'CREATED':
case 'DTEND':
case 'DTSTAMP':
case 'DTSTART':
case 'DUE':
case 'EXDATE':
case 'LAST-MODIFIED':
case 'RDATE':
case 'RECURRENCE-ID':
$this->value_type = self::VALUE_TYPE_DATE_TIME;
break;
case 'DURATION':
case 'TRIGGER':
$this->value_type = self::VALUE_TYPE_DURATION;
break;
case 'GEO':
$this->value_type = self::VALUE_TYPE_FLOAT;
break;
case 'PERCENT-COMPLETE':
case 'PRIORITY':
case 'REPEAT':
case 'SEQUENCE':
$this->value_type = self::VALUE_TYPE_INTEGER;
break;
case 'FREEBUSY':
$this->value_type = self::VALUE_TYPE_PERIOD;
break;
case 'RRULE':
$this->value_type = self::VALUE_TYPE_RECUR;
break;
case 'ATTACH':
case 'TZURL':
case 'URL':
$this->value_type = self::VALUE_TYPE_URI;
break;
case 'TZOFFSETFROM':
case 'TZOFFSETTO':
$this->value_type = self::VALUE_TYPE_UTC_OFFSET;
break;
default:
$this->value_type = self::VALUE_TYPE_TEXT;
break;
}
}
}