diff --git a/lib/REST/Target.php b/lib/REST/Target.php
new file mode 100644
index 00000000..bde4c6c1
--- /dev/null
+++ b/lib/REST/Target.php
@@ -0,0 +1,131 @@
+parseFragment($target);
+ $target = $this->parseQuery($target);
+ $this->path = $this->parsePath($target);
+ }
+
+ public function __toString(): string {
+ $out = "";
+ $path = [];
+ foreach ($this->path as $segment) {
+ if (is_null($segment)) {
+ if (!$path) {
+ $path[] = "..";
+ } else {
+ continue;
+ }
+ } elseif ($segment==".") {
+ $path[] = "%2E";
+ } elseif ($segment=="..") {
+ $path[] = "%2E%2E";
+ } else {
+ $path[] = rawurlencode(ValueInfo::normalize($segment, ValueInfo::T_STRING));
+ }
+ }
+ $path = implode("/", $path);
+ if (!$this->relative) {
+ $out .= "/";
+ }
+ $out .= $path;
+ if ($this->index && strlen($path)) {
+ $out .= "/";
+ }
+ if (strlen($this->query)) {
+ $out .= "?".$this->query;
+ }
+ if (strlen($this->fragment)) {
+ $out .= "#".rawurlencode($this->fragment);
+ }
+ return $out;
+ }
+
+ public static function normalize(string $target): string {
+ return (string) new self($target);
+ }
+
+ protected function parseFragment(string $target): string {
+ // store and strip off any fragment identifier and return the target without a fragment
+ $pos = strpos($target,"#");
+ if ($pos !== false) {
+ $this->fragment = rawurldecode(substr($target, $pos + 1));
+ $target = substr($target, 0, $pos);
+ }
+ return $target;
+ }
+
+ protected function parseQuery(string $target): string {
+ // store and strip off any query string and return the target without a query
+ // note that the function assumes any fragment identifier has already been stripped off
+ // unlike the other parts the query string is currently neither parsed nor normalized
+ $pos = strpos($target,"?");
+ if ($pos !== false) {
+ $this->query = substr($target, $pos + 1);
+ $target = substr($target, 0, $pos);
+ }
+ return $target;
+ }
+
+ protected function parsePath(string $target): array {
+ // note that the function assumes any fragment identifier or query has already been stripped off
+ // syntax-based normalization is applied to the path segments (see RFC 3986 sec. 6.2.2)
+ // duplicate slashes are NOT collapsed
+ if (substr($target, 0, 1)=="/") {
+ // if the path starts with a slash, strip it off
+ $target = substr($target, 1);
+ } else {
+ // otherwise this is a relative target
+ $this->relative = true;
+ }
+ if (!strlen($target)) {
+ // if the target is an empty string, this is an index target
+ $this->index = true;
+ } elseif (substr($target, -1, 1)=="/") {
+ // if the path ends in a slash, this is an index target and the slash should be stripped off
+ $this->index = true;
+ $target = substr($target, 0, strlen($target) -1);
+ }
+ // after stripping, explode the path parts
+ if (strlen($target)) {
+ $target = explode("/", $target);
+ $out = [];
+ // resolve relative path segments and decode each retained segment
+ foreach($target as $index => $segment) {
+ if ($segment==".") {
+ // self-referential segments can be ignored
+ continue;
+ } elseif ($segment=="..") {
+ if ($index==0) {
+ // if the first path segment refers to its parent (which we don't know about) we cannot output a correct path, so we do the best we can
+ $out[] = null;
+ } else {
+ // for any other segments after the first we pop off the last stored segment
+ array_pop($out);
+ }
+ } else {
+ // any other segment is decoded and retained
+ $out[] = rawurldecode($segment);
+ }
+ }
+ return $out;
+ } else {
+ return [];
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/cases/REST/TestTarget.php b/tests/cases/REST/TestTarget.php
new file mode 100644
index 00000000..08555d8f
--- /dev/null
+++ b/tests/cases/REST/TestTarget.php
@@ -0,0 +1,66 @@
+ */
+class TestTarget extends \JKingWeb\Arsse\Test\AbstractTest {
+
+ /** @dataProvider provideTargetUrls */
+ public function testParseTargetUrl(string $target, array $path, bool $relative, bool $index, string $query, string $fragment, string $normalized) {
+ $test = new Target($target);
+ $this->assertEquals($path, $test->path, "Path does not match");
+ $this->assertSame($path, $test->path, "Path does not match exactly");
+ $this->assertSame($relative, $test->relative, "Relative flag does not match");
+ $this->assertSame($index, $test->index, "Index flag does not match");
+ $this->assertSame($query, $test->query, "Query does not match");
+ $this->assertSame($fragment, $test->fragment, "Fragment does not match");
+ }
+
+ /** @dataProvider provideTargetUrls */
+ public function testNormalizeTargetUrl(string $target, array $path, bool $relative, bool $index, string $query, string $fragment, string $normalized) {
+ $test = new Target("");
+ $test->path = $path;
+ $test->relative = $relative;
+ $test->index = $index;
+ $test->query = $query;
+ $test->fragment = $fragment;
+ $this->assertSame($normalized, (string) $test);
+ $this->assertSame($normalized, Target::normalize($target));
+ }
+
+ public function provideTargetUrls() {
+ return [
+ ["/", [], false, true, "", "", "/"],
+ ["", [], true, true, "", "", ""],
+ ["/index.php", ["index.php"], false, false, "", "", "/index.php"],
+ ["index.php", ["index.php"], true, false, "", "", "index.php"],
+ ["/ook/", ["ook"], false, true, "", "", "/ook/"],
+ ["ook/", ["ook"], true, true, "", "", "ook/"],
+ ["/eek/../ook/", ["ook"], false, true, "", "", "/ook/"],
+ ["eek/../ook/", ["ook"], true, true, "", "", "ook/"],
+ ["/./ook/", ["ook"], false, true, "", "", "/ook/"],
+ ["./ook/", ["ook"], true, true, "", "", "ook/"],
+ ["/../ook/", [null,"ook"], false, true, "", "", "/../ook/"],
+ ["../ook/", [null,"ook"], true, true, "", "", "../ook/"],
+ ["0", ["0"], true, false, "", "", "0"],
+ ["%6f%6F%6b", ["ook"], true, false, "", "", "ook"],
+ ["%2e%2E%2f%2E%2Fook%2f", [".././ook/"], true, false, "", "", "..%2F.%2Fook%2F"],
+ ["%2e%2E/%2E/ook%2f", ["..",".","ook/"], true, false, "", "", "%2E%2E/%2E/ook%2F"],
+ ["...", ["..."], true, false, "", "", "..."],
+ ["%2e%2e%2e", ["..."], true, false, "", "", "..."],
+ ["/?", [], false, true, "", "", "/"],
+ ["/#", [], false, true, "", "", "/"],
+ ["/?#", [], false, true, "", "", "/"],
+ ["#%2e", [], true, true, "", ".", "#."],
+ ["?%2e", [], true, true, "%2e", "", "?%2e"],
+ ["?%2e#%2f", [], true, true, "%2e", "/", "?%2e#%2F"],
+ ["#%2e?%2f", [], true, true, "", ".?/", "#.%3F%2F"],
+ ];
+ }
+}
\ No newline at end of file
diff --git a/tests/phpunit.xml b/tests/phpunit.xml
index 8ffa2b62..167ab875 100644
--- a/tests/phpunit.xml
+++ b/tests/phpunit.xml
@@ -65,15 +65,16 @@
cases/Db/SQLite3/Database/TestLabel.php
cases/Db/SQLite3/Database/TestCleanup.php
-
-
- cases/REST/NextCloudNews/TestVersions.php
- cases/REST/NextCloudNews/TestV1_2.php
-
-
- cases/REST/TinyTinyRSS/TestAPI.php
- cases/REST/TinyTinyRSS/TestIcon.php
-
+
+ cases/REST/TestTarget.php
+
+
+ cases/REST/NextCloudNews/TestVersions.php
+ cases/REST/NextCloudNews/TestV1_2.php
+
+
+ cases/REST/TinyTinyRSS/TestAPI.php
+ cases/REST/TinyTinyRSS/TestIcon.php
cases/Service/TestService.php