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

View File

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

View File

@ -2,8 +2,6 @@
namespace Lewisdale\Webmentions; namespace Lewisdale\Webmentions;
use League\Uri\Exceptions\SyntaxError;
use League\Uri\Uri;
use Lewisdale\Webmentions\Exceptions\InvalidTargetException; use Lewisdale\Webmentions\Exceptions\InvalidTargetException;
use Lewisdale\Webmentions\Exceptions\InvalidUrlException; use Lewisdale\Webmentions\Exceptions\InvalidUrlException;
use Lewisdale\Webmentions\Exceptions\SourceNotFoundException; 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 public function receiveWebmention(string $source, string $target): void
{ {
// Validate that both source and target are actual domains // 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(); throw new InvalidUrlException();
} }
// Validate that the webmention target is a domain I care about // 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(); 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; namespace Lewisdale\Webmentions;
use Lewisdale\Webmentions\Exceptions\InvalidSourceException;
use Symfony\Component\DomCrawler\Crawler; use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpClient\HttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
class Webmention { class Webmention
{
private readonly HttpClientInterface $client; private readonly HttpClientInterface $client;
function __construct() function __construct()
@ -14,15 +16,21 @@ class Webmention {
$this->client = HttpClient::create(); $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); $urls = $this->getUrls($source);
foreach($urls as $target) { foreach ($urls as $target) {
$this->sendWebmention($source, $target); $this->sendWebmention($source, $target);
} }
} }
private function getUrls(string $url) : array { private function getUrls(string $url): array
{
$page = file_get_contents($url); $page = file_get_contents($url);
$doc = new Crawler($page); $doc = new Crawler($page);
@ -31,28 +39,30 @@ class Webmention {
$target_url = $anchor->attributes->getNamedItem('href')->textContent; $target_url = $anchor->attributes->getNamedItem('href')->textContent;
if ($target_url !== null && strlen($target_url)) { if ($target_url !== null && strlen($target_url)) {
$urls[] = $target_url; $urls[] = $target_url;
} }
} }
return $urls; return $urls;
} }
private function sendWebmention(string $source, string $target) { private function sendWebmention(string $source, string $target)
{
$endpoint = $this->getWebmentionEndpoint($target); $endpoint = $this->getWebmentionEndpoint($target);
if ($endpoint) { if ($endpoint) {
echo "Sending for " . $endpoint . "<br />"; echo "Sending for " . $endpoint . "<br />";
$this->client->request('POST', $endpoint, $this->client->request('POST', $endpoint,
[ [
"body" => [ "body" => [
"source" => $source, "source" => $source,
"target" => $target "target" => $target,
] ],
]); ]);
} }
} }
private function getWebmentionEndpoint(string $url) : string | null { private function getWebmentionEndpoint(string $url): string|null
{
$response = $this->client->request('GET', $url); $response = $this->client->request('GET', $url);
return EndpointParser::parse($response); return EndpointParser::parse($response);
} }

View File

@ -48,15 +48,7 @@ class EndpointTest extends TestCase
$endpoint = new Endpoint($this->mockClient, $this->mockGateway); $endpoint = new Endpoint($this->mockClient, $this->mockGateway);
$endpoint->receiveWebmention($source, $target); $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(["https://my-valid-source.url", "htt://my-invalid-target.com"])]
#[TestWith(["my-invalid-source", "https://my-valid-target.com"])] #[TestWith(["my-invalid-source", "https://my-valid-target.com"])]
#[TestWith(["http:///an-invalid-source", "an-invalid-target"])] #[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));
}
}