Move URL into separate file, handle source URL validation for sending webmentions

This commit is contained in:
Lewis Dale 2023-03-17 10:19:50 +00:00
parent fca08de828
commit ec83d67bde
8 changed files with 125 additions and 60 deletions

26
composer.lock generated
View File

@ -1024,16 +1024,16 @@
"packages-dev": [
{
"name": "myclabs/deep-copy",
"version": "1.11.0",
"version": "1.11.1",
"source": {
"type": "git",
"url": "https://github.com/myclabs/DeepCopy.git",
"reference": "14daed4296fae74d9e3201d2c4925d1acb7aa614"
"reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/14daed4296fae74d9e3201d2c4925d1acb7aa614",
"reference": "14daed4296fae74d9e3201d2c4925d1acb7aa614",
"url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/7284c22080590fb39f2ffa3e9057f10a4ddd0e0c",
"reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c",
"shasum": ""
},
"require": {
@ -1071,7 +1071,7 @@
],
"support": {
"issues": "https://github.com/myclabs/DeepCopy/issues",
"source": "https://github.com/myclabs/DeepCopy/tree/1.11.0"
"source": "https://github.com/myclabs/DeepCopy/tree/1.11.1"
},
"funding": [
{
@ -1079,7 +1079,7 @@
"type": "tidelift"
}
],
"time": "2022-03-03T13:19:32+00:00"
"time": "2023-03-08T13:26:56+00:00"
},
{
"name": "nikic/php-parser",
@ -1568,16 +1568,16 @@
},
{
"name": "phpunit/phpunit",
"version": "10.0.14",
"version": "10.0.16",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "7065dbebcb0f66cf16a45fc9cfc28c2351e06169"
"reference": "07d386a11ac7094032900f07cada1c8975d16607"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/7065dbebcb0f66cf16a45fc9cfc28c2351e06169",
"reference": "7065dbebcb0f66cf16a45fc9cfc28c2351e06169",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/07d386a11ac7094032900f07cada1c8975d16607",
"reference": "07d386a11ac7094032900f07cada1c8975d16607",
"shasum": ""
},
"require": {
@ -1609,7 +1609,7 @@
"sebastian/version": "^4.0"
},
"suggest": {
"ext-soap": "*"
"ext-soap": "To be able to generate mocks based on WSDL files"
},
"bin": [
"phpunit"
@ -1648,7 +1648,7 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"source": "https://github.com/sebastianbergmann/phpunit/tree/10.0.14"
"source": "https://github.com/sebastianbergmann/phpunit/tree/10.0.16"
},
"funding": [
{
@ -1664,7 +1664,7 @@
"type": "tidelift"
}
],
"time": "2023-03-01T05:37:49+00:00"
"time": "2023-03-13T09:02:40+00:00"
},
{
"name": "sebastian/cli-parser",

View File

@ -3,8 +3,11 @@
require_once __DIR__ . "/vendor/autoload.php";
use Lewisdale\Webmentions\Endpoint;
use Lewisdale\Webmentions\Exceptions\InvalidSourceException;
use Lewisdale\Webmentions\Exceptions\InvalidTargetException;
use Lewisdale\Webmentions\Exceptions\InvalidUrlException;
use Lewisdale\Webmentions\Exceptions\SourceNotFoundException;
use Lewisdale\Webmentions\Exceptions\TargetNotMentionedException;
use Lewisdale\Webmentions\Gateways\SqliteGateway;
use Lewisdale\Webmentions\Router\Request;
use Lewisdale\Webmentions\Router\Response;
@ -18,7 +21,13 @@ $router = new Router();
$router->post("/send", function (Request $req, Response $response) {
$mentioner = new Webmention();
$source = $req->query["source"];
$mentioner->sendForPage($source);
try {
$mentioner->sendForPage($source);
return;
} catch (InvalidSourceException $e) {
$response->status_code = StatusCode::BadRequest;
return $e->getMessage();
}
});
$router->post("/endpoint", function (Request $req, Response $response) {
@ -29,21 +38,22 @@ $router->post("/endpoint", function (Request $req, Response $response) {
try {
$endpoint->receiveWebmention($source, $target);
} catch (InvalidUrlException $e) {
} catch (InvalidUrlException) {
$response->status_code = StatusCode::BadRequest;
return "Source and target must be valid URLs";
} catch (InvalidTargetException $e) {
} catch (InvalidTargetException) {
$response->status_code = StatusCode::BadRequest;
return "Target must be on the domain lewisdale.dev";
} catch (\Lewisdale\Webmentions\Exceptions\SourceNotFoundException $e) {
} catch (SourceNotFoundException $e) {
$response->status_code = StatusCode::BadRequest;
return "Source URL was unreachable";
} catch (\Lewisdale\Webmentions\Exceptions\TargetNotMentionedException) {
} catch (TargetNotMentionedException) {
$response->status_code = StatusCode::BadRequest;
return "Source does not mention the target";
}
$response->status_code = StatusCode::Created;
return;
});
$router->get('/', fn($req, $res) => "<h1>Webmention server</h1>");

View File

@ -2,8 +2,6 @@
namespace Lewisdale\Webmentions;
use League\Uri\Exceptions\SyntaxError;
use League\Uri\Uri;
use Lewisdale\Webmentions\Exceptions\InvalidTargetException;
use Lewisdale\Webmentions\Exceptions\InvalidUrlException;
use Lewisdale\Webmentions\Exceptions\SourceNotFoundException;
@ -25,29 +23,15 @@ class Endpoint
{
}
public function validateUrl(string $url): bool
{
try {
$uri = Uri::createFromString($url);
$scheme = $uri->getScheme();
$schemeValid = in_array($scheme, ["http", "https"]);
return $schemeValid && !!filter_var($url, FILTER_VALIDATE_URL);
} catch (SyntaxError $e) {
return false;
}
}
public function receiveWebmention(string $source, string $target): void
{
// Validate that both source and target are actual domains
if (!$this->validateUrl($source) || !$this->validateUrl($target)) {
if (!Url::validateUrl($source) || !Url::validateUrl($target)) {
throw new InvalidUrlException();
}
// Validate that the webmention target is a domain I care about
if (!str_contains($target, "https://lewisdale.dev")) {
if (!Url::matchesHost($target, "lewisdale.dev")) {
throw new InvalidTargetException();
}

View File

@ -0,0 +1,11 @@
<?php declare(strict_types=1);
namespace Lewisdale\Webmentions\Exceptions;
class InvalidSourceException extends \Exception
{
function __construct()
{
parent::__construct("Source url is invalid");
}
}

30
src/Url.php Normal file
View File

@ -0,0 +1,30 @@
<?php declare(strict_types=1);
namespace Lewisdale\Webmentions;
use League\Uri\Exceptions\SyntaxError;
use League\Uri\Uri;
class Url
{
public static function matchesHost(string $source, string $host): bool
{
$uri = Uri::createFromString($source);
return $uri->getHost() === $host;
}
public static function validateUrl(string $url): bool
{
try {
$uri = Uri::createFromString($url);
$scheme = $uri->getScheme();
$schemeValid = in_array($scheme, ["http", "https"]);
[, $tld] = explode(".", $uri->getHost() ?? "");
return !empty($tld) && $schemeValid && !!filter_var($url, FILTER_VALIDATE_URL);
} catch (SyntaxError) {
return false;
}
}
}

View File

@ -2,11 +2,13 @@
namespace Lewisdale\Webmentions;
use Lewisdale\Webmentions\Exceptions\InvalidSourceException;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class Webmention {
class Webmention
{
private readonly HttpClientInterface $client;
function __construct()
@ -14,15 +16,21 @@ class Webmention {
$this->client = HttpClient::create();
}
public function sendForPage(string $source) : void {
public function sendForPage(string $source): void
{
if (!Url::matchesHost($source, "lewisdale.dev")) {
throw new InvalidSourceException();
}
$urls = $this->getUrls($source);
foreach($urls as $target) {
foreach ($urls as $target) {
$this->sendWebmention($source, $target);
}
}
private function getUrls(string $url) : array {
private function getUrls(string $url): array
{
$page = file_get_contents($url);
$doc = new Crawler($page);
@ -31,28 +39,30 @@ class Webmention {
$target_url = $anchor->attributes->getNamedItem('href')->textContent;
if ($target_url !== null && strlen($target_url)) {
$urls[] = $target_url;
}
$urls[] = $target_url;
}
}
return $urls;
}
private function sendWebmention(string $source, string $target) {
private function sendWebmention(string $source, string $target)
{
$endpoint = $this->getWebmentionEndpoint($target);
if ($endpoint) {
echo "Sending for " . $endpoint . "<br />";
$this->client->request('POST', $endpoint,
[
"body" => [
"source" => $source,
"target" => $target
]
]);
[
"body" => [
"source" => $source,
"target" => $target,
],
]);
}
}
private function getWebmentionEndpoint(string $url) : string | null {
private function getWebmentionEndpoint(string $url): string|null
{
$response = $this->client->request('GET', $url);
return EndpointParser::parse($response);
}

View File

@ -48,15 +48,7 @@ class EndpointTest extends TestCase
$endpoint = new Endpoint($this->mockClient, $this->mockGateway);
$endpoint->receiveWebmention($source, $target);
}
#[TestWith(["https://my.url.com", true])]
#[TestWith(["my.url.com", false])]
public function testValidatesUrls(string $url, bool $expected)
{
$endpoint = new Endpoint($this->mockClient, $this->mockGateway);
$this->assertEquals($expected, $endpoint->validateUrl($url), "Expected $url");
}
#[TestWith(["https://my-valid-source.url", "htt://my-invalid-target.com"])]
#[TestWith(["my-invalid-source", "https://my-valid-target.com"])]
#[TestWith(["http:///an-invalid-source", "an-invalid-target"])]

28
tests/UrlTest.php Normal file
View File

@ -0,0 +1,28 @@
<?php
use Lewisdale\Webmentions\Url;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\TestCase;
class UrlTest extends TestCase
{
#[TestWith(["https://lewisdale.dev/post/a-post", "lewisdale.dev", true])]
#[TestWith(["https://css-tricks.com/test/host", "lewisdale.dev", false])]
#[TestWith(["ht:/lewisdale.dev/not-a-valid-url", "lewisdale.dev", false])]
public function testItValidatesAHost(string $source, string $target, bool $expected)
{
$this->assertEquals($expected, Url::matchesHost($source, $target, $expected));
}
#[TestWith(["http://a-valid-http-url.com", true])]
#[TestWith(["ht://an-invalid-url.com", false])]
#[TestWith(["https://no-tld./", false])]
#[TestWith(["https://a-valid-https.com", true])]
#[TestWith(["ftp://wrong-protocol.com", false])]
public function testItValidatesAUrl(string $url, bool $expected)
{
$this->assertEquals($expected, Url::validateUrl($url));
}
}