diff --git a/src/Module/Api/Mastodon/Accounts/UpdateCredentials.php b/src/Module/Api/Mastodon/Accounts/UpdateCredentials.php index d3f4c15fe..b2a23cf42 100644 --- a/src/Module/Api/Mastodon/Accounts/UpdateCredentials.php +++ b/src/Module/Api/Mastodon/Accounts/UpdateCredentials.php @@ -21,8 +21,9 @@ namespace Friendica\Module\Api\Mastodon\Accounts; +use Friendica\Core\Logger; use Friendica\Module\BaseApi; -use Friendica\Util\Network; +use Friendica\Util\HTTPInputData; /** * @see https://docs.joinmastodon.org/methods/accounts/ @@ -34,9 +35,10 @@ class UpdateCredentials extends BaseApi self::login(self::SCOPE_WRITE); $uid = self::getCurrentUserID(); - $data = Network::postdata(); + $data = HTTPInputData::process(); + + Logger::info('Patch data', ['data' => $data]); - // @todo Parse the raw data that is in the "multipart/form-data" format self::unsupported('patch'); } } diff --git a/src/Util/HTTPInputData.php b/src/Util/HTTPInputData.php new file mode 100644 index 000000000..c1dba263c --- /dev/null +++ b/src/Util/HTTPInputData.php @@ -0,0 +1,295 @@ +. + * + */ + +namespace Friendica\Util; + +/** + * Derived from the work of Reid Johnson + * @see https://codereview.stackexchange.com/questions/69882/parsing-multipart-form-data-in-php-for-put-requests + */ +class HTTPInputData +{ + public static function process() + { + $content_parts = explode(';', static::getContentType()); + + $boundary = ''; + $encoding = ''; + + $content_type = array_shift($content_parts); + + foreach ($content_parts as $part) { + if (strpos($part, 'boundary') !== false) { + $part = explode('=', $part, 2); + if (!empty($part[1])) { + $boundary = '--' . $part[1]; + } + } elseif (strpos($part, 'charset') !== false) { + $part = explode('=', $part, 2); + if (!empty($part[1])) { + $encoding = $part[1]; + } + } + if ($boundary !== '' && $encoding !== '') { + break; + } + } + + if ($content_type == 'multipart/form-data') { + return self::fetchFromMultipart($boundary); + } + + // can be handled by built in PHP functionality + $content = static::getPhpInputContent(); + + $variables = json_decode($content, true); + + if (empty($variables)) { + parse_str($content, $variables); + } + + return ['variables' => $variables, 'files' => []]; + } + + private static function fetchFromMultipart(string $boundary) + { + $result = ['variables' => [], 'files' => []]; + + $stream = static::getPhpInputStream(); + + $sanity = fgets($stream, strlen($boundary) + 5); + + // malformed file, boundary should be first item + if (rtrim($sanity) !== $boundary) { + return $result; + } + + $raw_headers = ''; + + while (($chunk = fgets($stream)) !== false) { + if ($chunk === $boundary) { + continue; + } + + if (!empty(trim($chunk))) { + $raw_headers .= $chunk; + continue; + } + + $result = self::parseRawHeader($stream, $raw_headers, $boundary, $result); + + $raw_headers = ''; + } + + fclose($stream); + + return $result; + } + + private static function parseRawHeader($stream, string $raw_headers, string $boundary, array $result) + { + $variables = $result['variables']; + $files = $result['files']; + + $headers = []; + + foreach (explode("\r\n", $raw_headers) as $header) { + if (strpos($header, ':') === false) { + continue; + } + list($name, $value) = explode(':', $header, 2); + + $headers[strtolower($name)] = ltrim($value, ' '); + } + + if (!isset($headers['content-disposition'])) { + return ['variables' => $variables, 'files' => $files]; + } + + if (!preg_match('/^(.+); *name="([^"]+)"(; *filename="([^"]+)")?/', $headers['content-disposition'], $matches)) { + return ['variables' => $variables, 'files' => $files]; + } + + $name = $matches[2]; + $filename = $matches[4] ?? ''; + + if (!empty($filename)) { + $files[$name] = static::fetchFileData($stream, $boundary, $headers, $filename); + return ['variables' => $variables, 'files' => $files]; + } else { + $variables = self::fetchVariables($stream, $boundary, $headers, $name, $variables); + } + + return ['variables' => $variables, 'files' => $files]; + } + + protected static function fetchFileData($stream, string $boundary, array $headers, string $filename) + { + $error = UPLOAD_ERR_OK; + + if (isset($headers['content-type'])) { + $tmp = explode(';', $headers['content-type']); + + $contentType = $tmp[0]; + } else { + $contentType = 'unknown'; + } + + $tmpnam = tempnam(ini_get('upload_tmp_dir'), 'php'); + $fileHandle = fopen($tmpnam, 'wb'); + + if ($fileHandle === false) { + $error = UPLOAD_ERR_CANT_WRITE; + } else { + $lastLine = null; + while (($chunk = fgets($stream, 8096)) !== false && strpos($chunk, $boundary) !== 0) { + if ($lastLine !== null) { + if (fwrite($fileHandle, $lastLine) === false) { + $error = UPLOAD_ERR_CANT_WRITE; + break; + } + } + $lastLine = $chunk; + } + + if ($lastLine !== null && $error !== UPLOAD_ERR_CANT_WRITE) { + if (fwrite($fileHandle, rtrim($lastLine, "\r\n")) === false) { + $error = UPLOAD_ERR_CANT_WRITE; + } + } + } + + return [ + 'name' => $filename, + 'type' => $contentType, + 'tmp_name' => $tmpnam, + 'error' => $error, + 'size' => filesize($tmpnam) + ]; + } + + private static function fetchVariables($stream, string $boundary, array $headers, string $name, array $variables) + { + $fullValue = ''; + $lastLine = null; + + while (($chunk = fgets($stream)) !== false && strpos($chunk, $boundary) !== 0) { + if ($lastLine !== null) { + $fullValue .= $lastLine; + } + + $lastLine = $chunk; + } + + if ($lastLine !== null) { + $fullValue .= rtrim($lastLine, "\r\n"); + } + + if (isset($headers['content-type'])) { + $encoding = ''; + + foreach (explode(';', $headers['content-type']) as $part) { + if (strpos($part, 'charset') !== false) { + $part = explode($part, '=', 2); + if (isset($part[1])) { + $encoding = $part[1]; + } + break; + } + } + + if ($encoding !== '' && strtoupper($encoding) !== 'UTF-8' && strtoupper($encoding) !== 'UTF8') { + $tmp = mb_convert_encoding($fullValue, 'UTF-8', $encoding); + if ($tmp !== false) { + $fullValue = $tmp; + } + } + } + + $fullValue = $name . '=' . $fullValue; + + $tmp = []; + parse_str($fullValue, $tmp); + + return self::expandVariables(explode('[', $name), $variables, $tmp); + } + + private static function expandVariables(array $names, $variables, array $values) + { + if (!is_array($variables)) { + return $values; + } + + $name = rtrim(array_shift($names), ']'); + if ($name !== '') { + $name = $name . '=p'; + + $tmp = []; + parse_str($name, $tmp); + + $tmp = array_keys($tmp); + $name = reset($tmp); + } + + if ($name === '') { + $variables[] = reset($values); + } elseif (isset($variables[$name]) && isset($values[$name])) { + $variables[$name] = self::expandVariables($names, $variables[$name], $values[$name]); + } elseif (isset($values[$name])) { + $variables[$name] = $values[$name]; + } + + return $variables; + } + + /** + * Returns the current PHP input stream + * Mainly used for test doubling + * + * @return false|resource + */ + protected static function getPhpInputStream() + { + return fopen('php://input', 'rb'); + } + + /** + * Returns the content of the current PHP input + * Mainly used for test doubling + * + * @return false|string + */ + protected static function getPhpInputContent() + { + return file_get_contents('php://input'); + } + + /** + * Returns the content type string of the current call + * Mainly used for test doubling + * + * @return false|string + */ + protected static function getContentType() + { + return $_SERVER['CONTENT_TYPE'] ?? 'application/x-www-form-urlencoded'; + } +} diff --git a/tests/Util/HTTPInputDataDouble.php b/tests/Util/HTTPInputDataDouble.php new file mode 100644 index 000000000..391b9c82b --- /dev/null +++ b/tests/Util/HTTPInputDataDouble.php @@ -0,0 +1,97 @@ +. + * + */ + +namespace Friendica\Test\Util; + +use Friendica\Util\HTTPInputData; + +/** + * This class is used to enable testability for HTTPInputData + * It overrides the two PHP input functionality with custom content + */ +class HTTPInputDataDouble extends HTTPInputData +{ + /** @var false|resource */ + protected static $injectedStream = false; + /** @var false|string */ + protected static $injectedContent = false; + /** @var false|string */ + protected static $injectedContentType = false; + + /** + * injects the PHP input stream for a test + * + * @param false|resource $stream + */ + public static function setPhpInputStream($stream) + { + self::$injectedStream = $stream; + } + + /** + * injects the PHP input content for a test + * + * @param false|string $content + */ + public static function setPhpInputContent($content) + { + self::$injectedContent = $content; + } + + /** + * injects the PHP input content type for a test + * + * @param false|string $contentType + */ + public static function setPhpInputContentType($contentType) + { + self::$injectedContentType = $contentType; + } + + /** {@inheritDoc} */ + protected static function getPhpInputStream() + { + return static::$injectedStream; + } + + /** {@inheritDoc} */ + protected static function getPhpInputContent() + { + return static::$injectedContent; + } + + /** {@inheritDoc} */ + protected static function getContentType() + { + return static::$injectedContentType; + } + + protected static function fetchFileData($stream, string $boundary, array $headers, string $filename) + { + $data = parent::fetchFileData($stream, $boundary, $headers, $filename); + if (!empty($data['tmp_name'])) { + unlink($data['tmp_name']); + $data['tmp_name'] = $data['name']; + } + + return $data; + } +} diff --git a/tests/datasets/http/form-urlencoded-json.httpinput b/tests/datasets/http/form-urlencoded-json.httpinput new file mode 100644 index 000000000..4d7ca0f3e --- /dev/null +++ b/tests/datasets/http/form-urlencoded-json.httpinput @@ -0,0 +1 @@ +{"media_ids":[],"sensitive":false,"status":"Test Status","visibility":"private","spoiler_text":"Title"} diff --git a/tests/datasets/http/form-urlencoded.httpinput b/tests/datasets/http/form-urlencoded.httpinput new file mode 100644 index 000000000..3a71aff99 --- /dev/null +++ b/tests/datasets/http/form-urlencoded.httpinput @@ -0,0 +1 @@ +title=Test2 \ No newline at end of file diff --git a/tests/datasets/http/multipart-file.httpinput b/tests/datasets/http/multipart-file.httpinput new file mode 100644 index 000000000..a1bc0de31 Binary files /dev/null and b/tests/datasets/http/multipart-file.httpinput differ diff --git a/tests/datasets/http/multipart.httpinput b/tests/datasets/http/multipart.httpinput new file mode 100644 index 000000000..b1498e476 --- /dev/null +++ b/tests/datasets/http/multipart.httpinput @@ -0,0 +1,50 @@ +--43395968-f65c-437e-b536-5b33e3e3c7e5 +Content-Disposition: form-data; name="display_name" +Content-Transfer-Encoding: binary +Content-Type: multipart/form-data; charset=utf-8 +Content-Length: 9 + +User Name +--43395968-f65c-437e-b536-5b33e3e3c7e5 +Content-Disposition: form-data; name="note" +Content-Transfer-Encoding: binary +Content-Type: multipart/form-data; charset=utf-8 +Content-Length: 8 + +About me +--43395968-f65c-437e-b536-5b33e3e3c7e5 +Content-Disposition: form-data; name="locked" +Content-Transfer-Encoding: binary +Content-Type: multipart/form-data; charset=utf-8 +Content-Length: 5 + +false +--43395968-f65c-437e-b536-5b33e3e3c7e5 +Content-Disposition: form-data; name="fields_attributes[0][name]" +Content-Transfer-Encoding: binary +Content-Type: multipart/form-data; charset=utf-8 +Content-Length: 10 + +variable 1 +--43395968-f65c-437e-b536-5b33e3e3c7e5 +Content-Disposition: form-data; name="fields_attributes[0][value]" +Content-Transfer-Encoding: binary +Content-Type: multipart/form-data; charset=utf-8 +Content-Length: 7 + +value 1 +--43395968-f65c-437e-b536-5b33e3e3c7e5 +Content-Disposition: form-data; name="fields_attributes[1][name]" +Content-Transfer-Encoding: binary +Content-Type: multipart/form-data; charset=utf-8 +Content-Length: 10 + +variable 2 +--43395968-f65c-437e-b536-5b33e3e3c7e5 +Content-Disposition: form-data; name="fields_attributes[1][value]" +Content-Transfer-Encoding: binary +Content-Type: multipart/form-data; charset=utf-8 +Content-Length: 7 + +value 2 +--43395968-f65c-437e-b536-5b33e3e3c7e5-- diff --git a/tests/src/Util/HTTPInputDataTest.php b/tests/src/Util/HTTPInputDataTest.php new file mode 100644 index 000000000..5e8fd228f --- /dev/null +++ b/tests/src/Util/HTTPInputDataTest.php @@ -0,0 +1,152 @@ +. + * + */ + +namespace Friendica\Test\src\Util; + +use Friendica\Test\MockedTest; +use Friendica\Test\Util\HTTPInputDataDouble; +use Friendica\Util\HTTPInputData; + +/** + * Testing HTTPInputData + * + * @see HTTPInputData + */ +class HTTPInputDataTest extends MockedTest +{ + /** + * Returns the data stream for the unit test + * Each array element of the first hierarchy represents one test run + * Each array element of the second hierarchy represents the parameters, passed to the test function + * + * @return array[] + */ + public function dataStream() + { + return [ + 'multipart' => [ + 'contenttype' => 'multipart/form-data;boundary=43395968-f65c-437e-b536-5b33e3e3c7e5;charset=utf8', + 'input' => file_get_contents(__DIR__ . '/../../datasets/http/multipart.httpinput'), + 'expected' => [ + 'variables' => [ + 'display_name' => 'User Name', + 'note' => 'About me', + 'locked' => 'false', + 'fields_attributes' => [ + 0 => [ + 'name' => 'variable 1', + 'value' => 'value 1', + ], + 1 => [ + 'name' => 'variable 2', + 'value' => 'value 2', + ] + ] + ], + 'files' => [] + ] + ], + 'multipart-file' => [ + 'contenttype' => 'multipart/form-data;boundary=6d4d5a40-651a-4468-a62e-5a6ca2bf350d;charset=utf8', + 'input' => file_get_contents(__DIR__ . '/../../datasets/http/multipart-file.httpinput'), + 'expected' => [ + 'variables' => [ + 'display_name' => 'Vorname Nachname', + 'note' => 'About me', + 'fields_attributes' => [ + 0 => [ + 'name' => 'variable 1', + 'value' => 'value 1', + ], + 1 => [ + 'name' => 'variable 2', + 'value' => 'value 2', + ] + ] + ], + 'files' => [ + 'avatar' => [ + 'name' => '8ZUCS34Y5XNH', + 'type' => 'image/png', + 'tmp_name' => '8ZUCS34Y5XNH', + 'error' => 0, + 'size' => 349330 + ], + 'header' => [ + 'name' => 'V2B6Z1IICGPM', + 'type' => 'image/png', + 'tmp_name' => 'V2B6Z1IICGPM', + 'error' => 0, + 'size' => 1323635 + ] + ] + ] + ], + 'form-urlencoded' => [ + 'contenttype' => 'application/x-www-form-urlencoded;charset=utf8', + 'input' => file_get_contents(__DIR__ . '/../../datasets/http/form-urlencoded.httpinput'), + 'expected' => [ + 'variables' => [ + 'title' => 'Test2', + ], + 'files' => [] + ] + ], + 'form-urlencoded-json' => [ + 'contenttype' => 'application/x-www-form-urlencoded;charset=utf8', + 'input' => file_get_contents(__DIR__ . '/../../datasets/http/form-urlencoded-json.httpinput'), + 'expected' => [ + 'variables' => [ + 'media_ids' => [], + 'sensitive' => false, + 'status' => 'Test Status', + 'visibility' => 'private', + 'spoiler_text' => 'Title' + ], + 'files' => [] + ] + ] + ]; + } + + /** + * Tests the HTTPInputData::process() method + * + * @param string $contentType The content typer of the transmitted data + * @param string $input The input, we got from the data stream + * @param array $expected The expected output + * + * @dataProvider dataStream + * @see HTTPInputData::process() + */ + public function testHttpInput(string $contentType, string $input, array $expected) + { + HTTPInputDataDouble::setPhpInputContentType($contentType); + HTTPInputDataDouble::setPhpInputContent($input); + $stream = fopen('php://memory', 'r+'); + fwrite($stream, $input); + rewind($stream); + + HTTPInputDataDouble::setPhpInputStream($stream); + $output = HTTPInputDataDouble::process(); + $this->assertEqualsCanonicalizing($expected, $output); + } +}