From 5e2b655b43f5a74e249d9c069a44cf50ab5e5162 Mon Sep 17 00:00:00 2001 From: Hypolite Petovan Date: Tue, 10 Jan 2023 01:07:14 -0500 Subject: [PATCH] Add implementation of HTTP Media Type - Add charset extraction from DOMDocument - TESTS! --- src/Content/Text/HTML.php | 27 + src/Protocol/HTTP/MediaType.php | 237 ++++ tests/src/Content/Text/HTMLTest.php | 1257 +++++++++++++++++++++ tests/src/Protocol/HTTP/MediaTypeTest.php | 150 +++ 4 files changed, 1671 insertions(+) create mode 100644 src/Protocol/HTTP/MediaType.php create mode 100644 tests/src/Protocol/HTTP/MediaTypeTest.php diff --git a/src/Content/Text/HTML.php b/src/Content/Text/HTML.php index 557d62e12a..c65e1d9820 100644 --- a/src/Content/Text/HTML.php +++ b/src/Content/Text/HTML.php @@ -23,6 +23,7 @@ namespace Friendica\Content\Text; use DOMDocument; use DOMXPath; +use Friendica\Protocol\HTTP\MediaType; use Friendica\Content\Widget\ContactBlock; use Friendica\Core\Hook; use Friendica\Core\Renderer; @@ -1055,4 +1056,30 @@ class HTML return $result !== false && $result->length > 0; } + + /** + * @param DOMDocument $doc + * @return string|null Lowercase charset + */ + public static function extractCharset(DOMDocument $doc): ?string + { + $xpath = new DOMXPath($doc); + + $expression = "string(//meta[@charset]/@charset)"; + if ($charset = $xpath->evaluate($expression)) { + return strtolower($charset); + } + + try { + // This expression looks for a meta tag with the http-equiv attribute set to "content-type" ignoring case + // whose content attribute contains a "charset" string and returns its value + $expression = "string(//meta[@http-equiv][translate(@http-equiv, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') = 'content-type'][contains(translate(@content, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'charset')]/@content)"; + $mediaType = MediaType::fromContentType($xpath->evaluate($expression)); + if (isset($mediaType->parameters['charset'])) { + return strtolower($mediaType->parameters['charset']); + } + } catch(\InvalidArgumentException $e) {} + + return null; + } } diff --git a/src/Protocol/HTTP/MediaType.php b/src/Protocol/HTTP/MediaType.php new file mode 100644 index 0000000000..285f4e6df7 --- /dev/null +++ b/src/Protocol/HTTP/MediaType.php @@ -0,0 +1,237 @@ +. + * + */ + +namespace Friendica\Protocol\HTTP; + +/** + * @see https://httpwg.org/specs/rfc9110.html#media.type + * + * @property-read string $type + * @property-read string $subType + * @property-read string $parameters + */ +final class MediaType +{ + const DQUOTE = '"'; + const DIGIT = '0-9'; + const ALPHA = 'a-zA-Z'; + + // @see https://www.charset.org/charsets/us-ascii + const VCHAR = "\\x21-\\x7E"; + + const SYMBOL_NO_DELIM = "!#$%&'*+-.^_`|~"; + + const OBSTEXT = "\\x80-\\xFF"; + + const QDTEXT = "\t \\x21\\x23-\\x5B\\x5D-\\x7E" . self::OBSTEXT; + + /** + * @var string + */ + private $type; + + /** + * @var @string + */ + private $subType; + + /** + * @var string[] + */ + private $parameters; + + public function __construct(string $type, string $subType, array $parameters = []) + { + if (!self::isToken($type)) { + throw new \InvalidArgumentException("Type isn't a valid token: " . $type); + } + + if (!self::isToken($subType)) { + throw new \InvalidArgumentException("Subtype isn't a valid token: " . $subType); + } + + foreach ($parameters as $key => $value) { + if (!self::isToken($key)) { + throw new \InvalidArgumentException("Parameter key isn't a valid token: " . $key); + } + + if (!self::isToken($value) && !self::isQuotableString($value)) { + throw new \InvalidArgumentException("Parameter value isn't a valid token or a quotable string: " . $value); + } + } + + $this->type = $type; + $this->subType = $subType; + $this->parameters = $parameters; + } + + public function __get(string $name) + { + if (!isset($this->$name)) { + throw new \InvalidArgumentException('Unknown property ' . $name); + } + + return $this->$name; + } + + public static function fromContentType(string $contentType): self + { + if (!$contentType) { + throw new \InvalidArgumentException('Provided string is empty'); + } + + $parts = explode(';', $contentType); + $mimeTypeParts = explode('/', trim(array_shift($parts))); + if (count($mimeTypeParts) !== 2) { + throw new \InvalidArgumentException('Provided string doesn\'t look like a MIME type: ' . $contentType); + } + + list($type, $subType) = $mimeTypeParts; + + $parameters = []; + foreach ($parts as $parameterString) { + if (!trim($parameterString)) { + continue; + } + + $parameterParts = explode('=', trim($parameterString)); + + if (count($parameterParts) < 2) { + throw new \InvalidArgumentException('Parameter lacks a value: ' . $parameterString); + } + + if (count($parameterParts) > 2) { + throw new \InvalidArgumentException('Parameter has too many values: ' . $parameterString); + } + + list($key, $value) = $parameterParts; + + if (!self::isToken($value) && !self::isQuotedString($value)) { + throw new \InvalidArgumentException("Parameter value isn't a valid token or a quoted string: \"" . $value . '"'); + } + + if (self::isQuotedString($value)) { + $value = self::extractQuotedStringValue($value); + } + + // Parameter keys are case-insensitive, values are not + $parameters[strtolower($key)] = $value; + } + + return new self($type, $subType, $parameters); + } + + public function __toString(): string + { + $parameters = $this->parameters; + + array_walk($parameters, function (&$value, $key) { + $value = '; ' . $key . '=' . (self::isToken($value) ? $value : '"' . addcslashes($value, '"\\') . '"'); + }); + + return $this->type . '/' . $this->subType . implode($parameters); + } + + /** + * token = 1*tchar + * tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" + * / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" + * / DIGIT / ALPHA + * ; any VCHAR, except delimiters + * + * @see https://httpwg.org/specs/rfc9110.html#tokens + * + * @param string $string + * @return false|int + */ + private static function isToken(string $string) + { + $symbol = preg_quote(self::SYMBOL_NO_DELIM, '/'); + $digit = self::DIGIT; + $alpha = self::ALPHA; + + $pattern = "/^[$symbol$digit$alpha]+$/"; + + return preg_match($pattern, $string); + } + + /** + * quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE + * qdtext = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text + * + * @see https://httpwg.org/specs/rfc9110.html#quoted.strings + * + * @param string $string + * @return bool + */ + private static function isQuotedString(string $string): bool + { + $dquote = self::DQUOTE; + + $vchar = self::VCHAR; + + $obsText = self::OBSTEXT; + + $qdtext = '[' . self::QDTEXT . ']'; + + $quotedPair = "\\\\[\t $vchar$obsText]"; + + $pattern = "/^$dquote(?:$qdtext|$quotedPair)*$dquote$/"; + + return preg_match($pattern, $string); + } + + /** + * Is the string an extracted quoted string value? + * + * @param string $string + * @return bool + */ + private static function isQuotableString(string $string): bool + { + $vchar = self::VCHAR; + + $obsText = self::OBSTEXT; + + $qdtext = '[' . self::QDTEXT . ']'; + + $quotedSingle = "[\t $vchar$obsText]"; + + $pattern = "/^(?:$qdtext|$quotedSingle)*$/"; + + return preg_match($pattern, $string); + } + + /** + * Extracts the value from a quoted-string, removing quoted pairs + * + * @param string $value + * @return string + */ + private static function extractQuotedStringValue(string $value): string + { + return preg_replace_callback('/^"(.*)"$/', function ($matches) { + $vchar = self::VCHAR; + $obsText = self::OBSTEXT; + return preg_replace("/\\\\([\t $vchar$obsText])/", '$1', $matches[1]); + }, $value); + } +} diff --git a/tests/src/Content/Text/HTMLTest.php b/tests/src/Content/Text/HTMLTest.php index e480624b60..3225d7f60a 100644 --- a/tests/src/Content/Text/HTMLTest.php +++ b/tests/src/Content/Text/HTMLTest.php @@ -255,4 +255,1261 @@ its surprisingly good", { $this->assertFalse(HTML::checkRelMeLink($doc, $meUrl)); } + + public function dataExtractCharset(): array + { + return [ + 'https://github.com/friendica/friendica/issues/12488#issuecomment-1376002081' => [ + 'expected' => 'utf-8', + 'html' => "Mit Vollgas in den Abgrund … - Austria Insiderinfo Zum Inhalt springen
\"Austria
Startseite-Nachhaltigkeit-Natur- & Umweltschutz-Mit Vollgas in den Abgrund …

Mit Vollgas in den Abgrund …

… oder ist es doch nur ein wirklich ungünstiger Blickwinkel?

Viele sagen, dass wir sehenden Auges auf einen Abgrund zufahren. Ein Abgrund, den mittlerweile auch alle sehen. Bis auf die extrem Kurzsichtigen oder Blinden.

Aber trotzdem verharrt der Großteil von uns schön auf dem Gaspedal des Konsums. Ja, auch die Sehenden. Denn man habe es sich ja verdient, wozu würde man sonst den ganzen Tag arbeiten?

Warnung! Dieser Artikel kann Spuren von Ironie enthalten! Das Titelbild wurde unter Zuhilfenahme von DALL-E erstellt.

Abgrund? Künstliches Drama!

Würdest du mit deinem Auto auf eine Klippe zufahren und weiter Gas geben, obwohl du davon ausgehen musst, dass sich das mit durchgetretenem Gaspedal niemals ausgehen wird? Ein Absturz also unvermeidlich sein wird?

So ein Absturz wird im Rahmen der Klimakrise auch von vielen Sehenden in Kauf genommen. Denn seien wir einmal ganz ehrlich – wir wissen ja nicht wirklich, wie weit es dort dann hinuntergeht. Klar, es gibt Experten, die meinen, dass das sehr heftig werden wird, wenn wir so weitermachen. Aber man muss ja nicht immer gleich vom Schlimmsten ausgehen, oder?

Und was wäre, wenn diese sich vielleicht ja doch irren und es nur ein paar Zentimeter sind? Wenn es nur von unserem aktuellen Blickwinkel so schlimm aussieht? Mann kennt ja diese Bilder, wo sich der/die Fotograf:in einfach auf den Boden wirft, um ein künstliches Drama zu erzeugen. Dann hätten wir uns jahrelang umsonst so sehr angestrengt und eingeschränkt.

Brutale Einschränkungen. Echt brutal!

Öffis!

Brutal eingeschränkt! Mit kleineren E-Autos statt den schönen und sicheren (für den Fahrer) Hybrid-SUVs mit extra Stauraum und Überrollbügel. Oder noch schlimmer – in völlig überfüllte Öffis gezwängt, auf die man ohnehin immer ewig warten muss. Gemeinsam mit laut grölenden, betrunkenen und stinkenden, vor sich hin rülpsenden Fußballfans.

Nicht all-inclusive ins Glück!

Kein Urlaubsflug um 20 € zum Saufen für einen Abend nach Malle. Äh, wandern. Wandern im schönen Norden Mallorcas wollte ich natürlich sagen! Keine Kreuzfahrt ins Glück auf dem Traumschiff.

\"Ferienanlage
Ferienanlage in der Wüste mit Pool

Keine fremden Kulturen hautnah aus der sicheren All-Inklusive Ferienanlage mit extra großem Buffet und Security vor der Anlage heraus erleben. Bildung, so wichtig! Mir geht es natürlich nur um meine Bildung! Aber klar, der Pool der wirklich sauberen Anlage, da darf man sich wirklich nicht beschweren, der ist schon auch klasse. Und die Cocktails, und das Fitness-Angebot im Club.

Aber einmal fahre ich auf jeden Fall raus in die Slums, das muss man schon zumindest auch einmal gesehen haben. Um den Kindern Süßigkeiten vom Jeep zuzuwerfen und ihnen eine Freude zu machen. Um Verständnis für diese armen Menschen zu bekommen, denen die letzte Dürre / Überschwemmung / Tornado (zutreffendes bitte ankreuzen) wirklich mächtig zugesetzt hat.

Diese armen Leute stehen vor den Trümmern ihrer Existenz. Kein Haus. Kein Auto. Kein Boot. Wenig zu essen. Wer das nicht live gesehen hat, kann das Leid nicht verstehen – da gibst du mir doch recht, oder? Gut, wenn es Leute wie mich gibt, die Devisen ins Land bringen, um ihr Leid ein wenig zu verringern!

Keine Klimakrise – wäre wirklich alles umsonst?

Nehmen wir einmal an, die Klimakrise wäre nur erfunden. Erfunden vom „System“, welches uns nur unterjochen will, um Reiche noch reicher zu machen. Unterstützt von den Mainstream-Medien und dem Staatsfernsehen, die auch nur alle Teil des Systems wären. Was natürlicher völliger Schwachsinn ist, aber manche glauben das wirklich.

Wenn das alles nur erfunden wäre, würden wir also quasi völlig grundlos unsere Städte wieder lebenswerter machen.

\"Mirabellgarten
Mirabellgarten und die Festung Hohensalzburg – mehr davon!

Mit weniger Autos und somit Abgasen und Lärm. Weniger Asphalt und Parkplätze, dafür mehr Grün und Parks mit Spielplätzen, auf denen sich unsere Kinder und Enkel austoben können. In einer sehr guten und nahezu schadstofffreien Luft, ohne Angst haben zu müssen, dass sie von einem SUV umgenietet werden.

Mit einem sehr gut ausgebauten Öffi-System mit Bus und Bahn und einem ausgezeichneten Radwegenetz. Keine Zeit mehr in Staus verbringen. Sich nicht mehr mit dem Fahrrad zwischen stinkenden Autokolonnen durchschlängeln zu müssen, mit der ständigen Angst im Nacken, von einem gereizten und gestressten Autofahrer abgeschossen zu werden.

Wäre so etwas nicht erstrebenswert? Mit oder ohne Klimakrise? Mit dem zusätzlich guten Gefühl, dass wir die Kurve vor dem Abgrund noch schaffen können? Völlig egal, wie hoch der Abgrund am Ende wirklich ist?

Ähnliche Beiträge

Hinterlasse einen Kommentar Antworten abbrechen

Page load link
Nach oben
+", + ], + 'meta http-equiv content-type' => [ + 'expected' => 'utf-8', + 'html' => '', + ], + 'meta http-equiv Content-Type' => [ + 'expected' => 'utf-8', + 'html' => '', + ], + 'meta http-equiv Content-Type no charset' => [ + 'expected' => null, + 'html' => '', + ], + 'meta charset' => [ + 'expected' => 'utf-8', + 'html' => '', + ], + 'meta charset no quotes' => [ + 'expected' => 'utf-8', + 'html' => '', + ], + 'meta charSet' => [ + 'expected' => 'utf-8', + 'html' => '', + ], +// Can't test in Woodpecker without tripping PHPUnit, even with the error-suppressing operator +// 'invalid html' => [ +// 'expected' => null, +// 'html' => '', +// ] + ]; + } + + /** + * @dataProvider dataExtractCharset + * + * @param string|null $expected + * @param string $html + * @return void + */ + public function testExtractCharset(?string $expected, string $html) + { + $doc = new \DOMDocument(); + @$doc->loadHTML($html, LIBXML_NOERROR); + + $this->assertEquals($expected, HTML::extractCharset($doc)); + } } diff --git a/tests/src/Protocol/HTTP/MediaTypeTest.php b/tests/src/Protocol/HTTP/MediaTypeTest.php new file mode 100644 index 0000000000..e863d75ea0 --- /dev/null +++ b/tests/src/Protocol/HTTP/MediaTypeTest.php @@ -0,0 +1,150 @@ +. + * + */ + +namespace Friendica\Test\src\Protocol\HTTP; + +use Friendica\Protocol\HTTP\MediaType; + +class MediaTypeTest extends \PHPUnit\Framework\TestCase +{ + public function dataValid(): array + { + return [ + 'HTML UTF-8' => [ + 'expected' => new MediaType('text', 'html', ['charset' => 'utf-8']), + 'content-type' => 'text/html; charset=utf-8', + ], + 'HTML Northern Europe' => [ + 'expected' => new MediaType('text', 'html', ['charset' => 'ISO-8859-4']), + 'content-type' => 'text/html; charset=ISO-8859-4', + ], + 'multipart/form-data' => [ + 'expected' => new MediaType('multipart', 'form-data', ['boundary' => '---------------------------974767299852498929531610575']), + 'content-type' => 'multipart/form-data; boundary=---------------------------974767299852498929531610575', + ], + 'Multiple parameters' => [ + 'expected' => new MediaType('application', 'octet-stream', ['charset' => 'ISO-8859-4', 'another' => 'parameter']), + 'content-type' => 'application/octet-stream; charset=ISO-8859-4 ; another=parameter', + ], + 'No parameters' => [ + 'expected' => new MediaType('application', 'vnd.adobe.air-application-installer-package+zip'), + 'content-type' => 'application/vnd.adobe.air-application-installer-package+zip', + ], + 'No parameters colon' => [ + 'expected' => new MediaType('application', 'vnd.adobe.air-application-installer-package+zip'), + 'content-type' => 'application/vnd.adobe.air-application-installer-package+zip;', + ], + 'No parameters space colon' => [ + 'expected' => new MediaType('application', 'vnd.adobe.air-application-installer-package+zip'), + 'content-type' => 'application/vnd.adobe.air-application-installer-package+zip ;', + ], + 'No parameters space colon space' => [ + 'expected' => new MediaType('application', 'vnd.adobe.air-application-installer-package+zip'), + 'content-type' => 'application/vnd.adobe.air-application-installer-package+zip ; ', + ], + 'Parameter quoted string' => [ + 'expected' => new MediaType('text', 'html', ['parameter' => 'Quoted string with a space and a "double-quote"']), + 'content-type' => 'text/html; parameter="Quoted string with a space and a \"double-quote\""', + ] + ]; + } + + /** + * @dataProvider dataValid + * + * @param MediaType $expected + * @param string $contentType + * @return void + */ + public function testValid(MediaType $expected, string $contentType) + { + $this->assertEquals($expected, MediaType::fromContentType($contentType)); + } + + public function dataInvalid(): array + { + return [ + 'no slash' => ['application'], + 'two slashes' => ['application/octet/stream'], + 'parameter no value' => ['application/octet-stream ; parameter'], + 'parameter too many values' => ['application/octet-stream ; parameter=value1=value2'], + 'type non token' => ['appli"cation/octet-stream'], + 'subtype non token' => ['application/octet\-stream'], + 'parameter name non token' => ['application/octet-stream; para"meter=value'], + 'parameter value invalid' => ['application/octet-stream; parameter="value"value'], + ]; + } + + /** + * @dataProvider dataInvalid + * + * @param string $contentType + * @return void + */ + public function testInvalid(string $contentType) + { + $this->expectException(\InvalidArgumentException::class); + + MediaType::fromContentType($contentType); + } + + public function dataToString(): array + { + return [ + 'HTML UTF-8' => [ + 'content-type' => 'text/html; charset=utf-8', + 'mediaType' => new MediaType('text', 'html', ['charset' => 'utf-8']), + ], + 'HTML Northern Europe' => [ + 'expected' => 'text/html; charset=ISO-8859-4', + 'mediaType' => new MediaType('text', 'html', ['charset' => 'ISO-8859-4']), + ], + 'multipart/form-data' => [ + 'expected' => 'multipart/form-data; boundary=---------------------------974767299852498929531610575', + 'mediaType' => new MediaType('multipart', 'form-data', ['boundary' => '---------------------------974767299852498929531610575']), + ], + 'Multiple parameters' => [ + 'expected' => 'application/octet-stream; charset=ISO-8859-4; another=parameter', + 'mediaType' => new MediaType('application', 'octet-stream', ['charset' => 'ISO-8859-4', 'another' => 'parameter']), + ], + 'No parameters' => [ + 'expected' => 'application/vnd.adobe.air-application-installer-package+zip', + 'mediaType' => new MediaType('application', 'vnd.adobe.air-application-installer-package+zip'), + ], + 'Parameter quoted string' => [ + 'expected' => 'text/html; parameter="Quoted string with a space and a \"double-quote\""', + 'mediaType' => new MediaType('text', 'html', ['parameter' => 'Quoted string with a space and a "double-quote"']), + ], + ]; + } + + /** + * @dataProvider dataToString + * + * @param string $expected + * @param MediaType $mediaType + * @return void + */ + public function testToString(string $expected, MediaType $mediaType) + { + $this->assertEquals($expected, $mediaType->__toString()); + } +} -- 2.39.5