@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
phorge
phabricator
1<?php
2
3/**
4 * @phutil-external-symbol class mysqli
5 */
6final class AphrontMySQLiDatabaseConnection
7 extends AphrontBaseMySQLDatabaseConnection {
8
9 private $connectionOpen = false;
10
11 public function escapeUTF8String($string) {
12 $this->validateUTF8String($string);
13 return $this->escapeBinaryString($string);
14 }
15
16 public function escapeBinaryString($string) {
17 return $this->requireConnection()->escape_string($string);
18 }
19
20 public function getInsertID() {
21 return $this->requireConnection()->insert_id;
22 }
23
24 public function getAffectedRows() {
25 return $this->requireConnection()->affected_rows;
26 }
27
28 protected function closeConnection() {
29 if ($this->connectionOpen) {
30 $this->requireConnection()->close();
31 $this->connectionOpen = false;
32 }
33 }
34
35 protected function connect() {
36 if (!class_exists('mysqli', false)) {
37 throw new Exception(pht(
38 'About to call new %s, but the PHP MySQLi extension is not available!',
39 'mysqli()'));
40 }
41
42 $user = $this->getConfiguration('user');
43 $host = $this->getConfiguration('host');
44 $port = $this->getConfiguration('port');
45 $database = $this->getConfiguration('database');
46
47 $pass = $this->getConfiguration('pass');
48 if ($pass instanceof PhutilOpaqueEnvelope) {
49 $pass = $pass->openEnvelope();
50 }
51
52 // If the host is "localhost", the port is ignored and mysqli attempts to
53 // connect over a socket.
54 if ($port) {
55 if ($host === 'localhost' || $host === null) {
56 $host = '127.0.0.1';
57 }
58 }
59
60 // In PHP 8.1, the default "report mode" for MySQLi has changed, which
61 // causes MySQLi to raise exceptions. Disable exceptions to align behavior
62 // with older default behavior under MySQLi, which this code expects.
63 // https://www.php.net/manual/mysqli-driver.report-mode.php
64 // https://www.php.net/manual/migration81.incompatible.php#migration81.incompatible.mysqli
65 // TODO: Plausibly, this code could be updated to use MySQLi exceptions
66 // to handle errors. See https://we.phorge.it/T16341
67 mysqli_report(MYSQLI_REPORT_OFF);
68
69 $conn = mysqli_init();
70
71 $timeout = $this->getConfiguration('timeout');
72 if ($timeout) {
73 $conn->options(MYSQLI_OPT_CONNECT_TIMEOUT, $timeout);
74 }
75
76 if ($this->getPersistent()) {
77 $host = 'p:'.$host;
78 }
79
80 $trap = new PhutilErrorTrap();
81
82 $ok = @$conn->real_connect(
83 $host,
84 $user,
85 $pass,
86 $database,
87 $port);
88
89 $call_error = $trap->getErrorsAsString();
90 $trap->destroy();
91
92 $errno = $conn->connect_errno;
93 if ($errno) {
94 $error = $conn->connect_error;
95 $this->throwConnectionException($errno, $error, $user, $host);
96 }
97
98 // See T13403. If the parameters to "real_connect()" are wrong, it may
99 // fail without setting an error code. In this case, raise a generic
100 // exception. (One way to reproduce this is to pass a string to the
101 // "port" parameter.)
102
103 if (!$ok) {
104 if (strlen($call_error)) {
105 $message = pht(
106 'mysqli->real_connect() failed: %s',
107 $call_error);
108 } else {
109 $message = pht(
110 'mysqli->real_connect() failed, but did not set an error code '.
111 'or emit a message.');
112 }
113
114 $this->throwConnectionException(
115 self::CALLERROR_CONNECT,
116 $message,
117 $user,
118 $host);
119 }
120
121 // See T13238. Attempt to prevent "LOAD DATA LOCAL INFILE", which allows a
122 // malicious server to ask the client for any file. At time of writing,
123 // this option MUST be set after "real_connect()" on all PHP versions.
124 $conn->options(MYSQLI_OPT_LOCAL_INFILE, 0);
125
126 $this->connectionOpen = true;
127
128 $ok = @$conn->set_charset('utf8mb4');
129 if (!$ok) {
130 $ok = $conn->set_charset('binary');
131 }
132
133 return $conn;
134 }
135
136 protected function rawQuery($raw_query) {
137 $conn = $this->requireConnection();
138 $time_limit = $this->getQueryTimeout();
139
140 // If we have a query time limit, run this query synchronously but use
141 // the async API. This allows us to kill queries which take too long
142 // without requiring any configuration on the server side.
143 if ($time_limit) {
144 $conn->query($raw_query, MYSQLI_ASYNC);
145
146 $read = array($conn);
147 $error = array($conn);
148 $reject = array($conn);
149
150 $result = mysqli::poll($read, $error, $reject, $time_limit);
151
152 if ($result === false) {
153 $this->closeConnection();
154 throw new Exception(
155 pht('Failed to poll mysqli connection!'));
156 } else if ($result === 0) {
157 $this->closeConnection();
158 throw new AphrontQueryTimeoutQueryException(
159 pht(
160 'Query timed out after %s second(s)!',
161 new PhutilNumber($time_limit)));
162 }
163
164 return @$conn->reap_async_query();
165 }
166
167 $trap = new PhutilErrorTrap();
168
169 $result = @$conn->query($raw_query);
170
171 $err = $trap->getErrorsAsString();
172 $trap->destroy();
173
174 // See T13238 and PHI1014. Sometimes, the call to "$conn->query()" may fail
175 // without setting an error code on the connection. One way to reproduce
176 // this is to use "LOAD DATA LOCAL INFILE" with "mysqli.allow_local_infile"
177 // disabled.
178
179 // If we have no result and no error code, raise a synthetic query error
180 // with whatever error message was raised as a local PHP warning.
181
182 if (!$result) {
183 $error_code = $this->getErrorCode($conn);
184 if (!$error_code) {
185 if (strlen($err)) {
186 $message = $err;
187 } else {
188 $message = pht(
189 'Call to "mysqli->query()" failed, but did not set an error '.
190 'code or emit an error message.');
191 }
192 $this->throwQueryCodeException(self::CALLERROR_QUERY, $message);
193 }
194 }
195
196 return $result;
197 }
198
199 protected function rawQueries(array $raw_queries) {
200 $conn = $this->requireConnection();
201
202 $have_result = false;
203 $results = array();
204
205 foreach ($raw_queries as $key => $raw_query) {
206 if (!$have_result) {
207 // End line in front of semicolon to allow single line comments at the
208 // end of queries.
209 $have_result = $conn->multi_query(implode("\n;\n\n", $raw_queries));
210 } else {
211 $have_result = $conn->next_result();
212 }
213
214 array_shift($raw_queries);
215
216 $result = $conn->store_result();
217 if (!$result && !$this->getErrorCode($conn)) {
218 $result = true;
219 }
220 $results[$key] = $this->processResult($result);
221 }
222
223 if ($conn->more_results()) {
224 throw new Exception(
225 pht('There are some results left in the result set.'));
226 }
227
228 return $results;
229 }
230
231 protected function freeResult($result) {
232 $result->free_result();
233 }
234
235 protected function fetchAssoc($result) {
236 return $result->fetch_assoc();
237 }
238
239 protected function getErrorCode($connection) {
240 return $connection->errno;
241 }
242
243 protected function getErrorDescription($connection) {
244 return $connection->error;
245 }
246
247 public function asyncQuery($raw_query) {
248 $this->checkWrite($raw_query);
249 $async = $this->beginAsyncConnection();
250 $async->query($raw_query, MYSQLI_ASYNC);
251 return $async;
252 }
253
254 public static function resolveAsyncQueries(array $conns, array $asyncs) {
255 assert_instances_of($conns, self::class);
256 assert_instances_of($asyncs, 'mysqli');
257
258 $read = $error = $reject = array();
259 foreach ($asyncs as $async) {
260 $read[] = $error[] = $reject[] = $async;
261 }
262
263 if (!mysqli::poll($read, $error, $reject, 0)) {
264 return array();
265 }
266
267 $results = array();
268 foreach ($read as $async) {
269 $key = array_search($async, $asyncs, $strict = true);
270 $conn = $conns[$key];
271 $conn->endAsyncConnection($async);
272 $results[$key] = $conn->processResult($async->reap_async_query());
273 }
274 return $results;
275 }
276
277}