1: <?php
2: /*
3: * Copyright (c) 2015 @trashtoy
4: * https://github.com/trashtoy/
5: *
6: * Permission is hereby granted, free of charge, to any person obtaining a copy of
7: * this software and associated documentation files (the "Software"), to deal in
8: * the Software without restriction, including without limitation the rights to use,
9: * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
10: * Software, and to permit persons to whom the Software is furnished to do so,
11: * subject to the following conditions:
12: *
13: * The above copyright notice and this permission notice shall be included in all
14: * copies or substantial portions of the Software.
15: *
16: * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17: * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
18: * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
19: * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
20: * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
21: * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22: */
23: /**
24: * PHP class file.
25: * @auhtor trashtoy
26: * @since 2.2.0
27: */
28: namespace Peach\Http;
29:
30: use InvalidArgumentException;
31: use Peach\DT\HttpDateFormat;
32: use Peach\DT\Timestamp;
33: use Peach\Http\Header\HttpDate;
34: use Peach\Http\Header\QualityValues;
35: use Peach\Http\Header\Raw;
36:
37: /**
38: * HTTP メッセージを扱う際によく使われる機能を集めたユーティリティクラスです.
39: */
40: class Util
41: {
42: /**
43: * このクラスはインスタンス化できません
44: */
45: private function __construct() {}
46:
47: /**
48: * 指定された文字列が HTTP ヘッダー名として妥当かどうかを検証します.
49: * 文字列が半角アルファベット・数字・ハイフンから成る場合のみ妥当とします.
50: * 妥当な文字列でない場合は InvalidArgumentException をスローします.
51: *
52: * @param string $name ヘッダー名
53: * @throws InvalidArgumentException 引数がヘッダー名として不正だった場合
54: */
55: public static function validateHeaderName($name)
56: {
57: // @codeCoverageIgnoreStart
58: static $whiteList = array(
59: ":authority",
60: ":path",
61: ":method",
62: ":scheme",
63: ":status",
64: );
65: // @codeCoverageIgnoreEnd
66: if (in_array($name, $whiteList, true)) {
67: return;
68: }
69: if (!preg_match("/\\A[a-zA-Z0-9\\-]+\\z/", $name)) {
70: throw new InvalidArgumentException("{$name} is not a valid header name");
71: }
72: }
73:
74: /**
75: * 指定された文字列が HTTP ヘッダーの値として妥当かどうかを検証します.
76: *
77: * {@link https://tools.ietf.org/html/rfc7230 RFC 7230} で定義された以下の ABNF に基いて妥当性の判定を行います.
78: *
79: * <pre>
80: * header-field = field-name ":" OWS field-value OWS
81: *
82: * field-name = token
83: * field-value = *( field-content / obs-fold )
84: * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]
85: * field-vchar = VCHAR / obs-text
86: *
87: * obs-fold = CRLF 1*( SP / HTAB )
88: * </pre>
89: *
90: * 妥当な文字列でない場合は InvalidArgumentException をスローします.
91: * obs-text および obs-fold については RFC7230 で廃止されているため例外として処理します.
92: *
93: * @param string $value 検査対象のヘッダー値
94: * @throws InvalidArgumentException 引数がヘッダー値として不正だった場合
95: */
96: public static function validateHeaderValue($value)
97: {
98: if (!self::handleValidateHeaderValue($value)) {
99: throw new InvalidArgumentException("'{$value}' is not a valid header value");
100: }
101: }
102:
103: /**
104: * 引数がヘッダー値として妥当な場合のみ true を返します.
105: *
106: * @param string $value ヘッダー値
107: * @return bool 妥当なヘッダー値の場合は true, それ以外は false
108: */
109: private static function handleValidateHeaderValue($value)
110: {
111: $trimmed = trim($value);
112: if ($trimmed !== $value) {
113: return false;
114: }
115: if ($value === "") {
116: return true;
117: }
118: $bytes = str_split($value);
119: return (count($bytes) === 1) ? self::validateVCHAR($value) : self::validateBytes($bytes);
120: }
121:
122: /**
123: * 指定された文字列が印字可能文字と空白文字で構成されているかどうかを確認します.
124: * ただし文字列の先頭および末尾が空白文字だった場合は NG とします.
125: *
126: * @param array $bytes
127: * @return bool
128: */
129: private static function validateBytes($bytes)
130: {
131: $head = array_shift($bytes);
132: if (!self::validateVCHAR($head)) {
133: return false;
134: }
135: $tail = array_pop($bytes);
136: if (!self::validateVCHAR($tail)) {
137: return false;
138: }
139: foreach ($bytes as $chr) {
140: if (!self::validateVCHAR($chr) && $chr !== " " && $chr !== "\t") {
141: return false;
142: }
143: }
144: return true;
145: }
146:
147: /**
148: * 引数の 1 文字について, 印字可能文字かどうかを判定します.
149: * @param string $chr 検査対象の文字
150: * @return bool 印字可能文字の場合のみ true
151: */
152: private static function validateVCHAR($chr)
153: {
154: $byte = ord($chr);
155: return (0x21 <= $byte && $byte <= 0x7E);
156: }
157:
158: /**
159: * 指定された Request の If-Modified-Since および If-None-Match ヘッダーを参照し,
160: * この Request がキャッシュしているリソースの最新版が存在するかどうかを判定します.
161: *
162: * @param Request $request 判定対象の Request
163: * @param Timestamp $lastModified サーバー側リソースの最終更新日時
164: * @param string $etag サーバー側リソースの ETag
165: * @return bool
166: */
167: public static function checkResponseUpdate(Request $request, Timestamp $lastModified, $etag = null)
168: {
169: $ifModifiedSince = $request->getHeader("If-Modified-Since");
170: if (!($ifModifiedSince instanceof HttpDate)) {
171: return true;
172: }
173: $clientTime = $ifModifiedSince->getValue();
174: if ($clientTime->before($lastModified)) {
175: return true;
176: }
177:
178: return $etag !== $request->getHeader("If-None-Match")->getValue();
179: }
180:
181: /**
182: * 指定されたヘッダー名, ヘッダー値の組み合わせから
183: * HeaderField オブジェクトを構築します.
184: *
185: * @param string $name ヘッダー名
186: * @param string $value ヘッダー値
187: * @return HeaderField
188: */
189: public static function parseHeader($name, $value)
190: {
191: static $qNames = array(
192: "accept",
193: "accept-language",
194: "accept-encoding",
195: );
196: static $dNames = array(
197: "date",
198: "if-modified-since",
199: "last-modified",
200: );
201: $lName = strtolower($name);
202: if (in_array($lName, $qNames)) {
203: return new QualityValues($lName, self::parseQualityValue($value));
204: }
205: if (in_array($lName, $dNames)) {
206: $format = HttpDateFormat::getInstance();
207: $timestamp = Timestamp::parse($value, $format);
208: return new HttpDate($lName, $timestamp, $format);
209: }
210: if ($lName === "host") {
211: return new Raw(":authority", $value);
212: }
213:
214: return new Raw($lName, $value);
215: }
216:
217: /**
218: * qualitiy value を配列に変換します.
219: * 引数を "ja,en-us;q=0.8,en;q=0.6" とした場合, 返り値は以下の配列になります.
220: * <pre>
221: * array("ja" => 1.0, "en-us" => 0.8, "en" => 0.6)
222: * </pre>
223: *
224: * @param string $value
225: * @return array
226: */
227: private static function parseQualityValue($value)
228: {
229: $values = preg_split("/\\s*,\\s*/", $value);
230: $matched = array();
231: $qvList = array();
232: foreach ($values as $item) {
233: if (preg_match("/\\A([^;]+)\\s*;\\s*(.+)\\z/", $item, $matched)) {
234: $key = $matched[1];
235: $qvalue = self::parseQvalue($matched[2]);
236: } else {
237: $key = $item;
238: $qvalue = 1.0;
239: }
240: $qvList[$key] = $qvalue;
241: }
242: return $qvList;
243: }
244:
245: /**
246: * qvalue 形式の文字列 ("q=0.8" など) から小数部分を解析し, float として返します.
247: *
248: * @param string $qvalue "q=0.9" のような形式の文字列
249: * @return float qvalue の小数値. もしも不正な場合は 1.0
250: */
251: private static function parseQvalue($qvalue)
252: {
253: $matched = array();
254: if (preg_match("/\\Aq\\s*=\\s*([0-9\\.]+)\\z/", $qvalue, $matched)) {
255: $val = (float) $matched[1];
256: return (0.0 < $val && $val <= 1.0) ? $val : 1;
257: }
258: return 1;
259: }
260: }
261: