diff --git a/src/Exception.php b/src/Exception.php new file mode 100644 index 0000000..455419b --- /dev/null +++ b/src/Exception.php @@ -0,0 +1,11 @@ +hasHeader('Slug')) { + $slugs = $request->getHeader('Slug'); + // @CHECKME: First set header wins, is this correct? Or should it be the last one? + $path = reset($slugs); + } $method = $this->getRequestMethod($request); @@ -147,8 +153,8 @@ private function handle(string $method, string $path, $contents, $request): Resp // @FIXME: Add correct headers to resources (for instance allow DELETE on a GET resource) // ->withAddedHeader('Accept-Patch', 'text/ldpatch') // ->withAddedHeader('Accept-Post', 'text/turtle, application/ld+json, image/bmp, image/jpeg') - // ->withHeader('Allow', 'GET, HEAD, OPTIONS, PATCH, POST, PUT'); - // ; + // ->withHeader('Allow', 'GET, HEAD, OPTIONS, PATCH, POST, PUT') + //; switch ($method) { case 'DELETE': @@ -161,7 +167,7 @@ private function handle(string $method, string $path, $contents, $request): Resp if ($method === 'HEAD') { $response->getBody()->rewind(); $response->getBody()->write(''); - $response = $response->withStatus("204"); // CHECKME: nextcloud will remove the updates-via header - any objections to give the 'HEAD' request a 'no content' response type? + $response = $response->withStatus(204); // CHECKME: nextcloud will remove the updates-via header - any objections to give the 'HEAD' request a 'no content' response type? if ($this->pubsub) { $response = $response->withHeader("updates-via", $this->pubsub); } @@ -171,7 +177,7 @@ private function handle(string $method, string $path, $contents, $request): Resp case 'OPTIONS': $response = $response ->withHeader('Vary', 'Accept') - ->withStatus('204') + ->withStatus(204) ; break; @@ -198,7 +204,7 @@ private function handle(string $method, string $path, $contents, $request): Resp $mimetype = self::MIME_TYPE_DIRECTORY; } if ($pathExists === true) { - if ($mimetype === self::MIME_TYPE_DIRECTORY) { + if (isset($mimetype) && $mimetype === self::MIME_TYPE_DIRECTORY) { $contentType= explode(";", $request->getHeaderLine("Content-Type"))[0]; $slug = $request->getHeaderLine("Slug"); if ($slug) { @@ -207,9 +213,10 @@ private function handle(string $method, string $path, $contents, $request): Resp $filename = $this->guid(); } // FIXME: make this list complete for at least the things we'd expect (turtle, n3, jsonld, ntriples, rdf); - // FIXME: if no content type was passed, we should reject the request according to the spec; - switch ($contentType) { + case '': + // FIXME: if no content type was passed, we should reject the request according to the spec; + break; case "text/plain": $filename .= ".txt"; break; @@ -249,8 +256,7 @@ private function handle(string $method, string $path, $contents, $request): Resp } break; default: - $message = vsprintf(self::ERROR_UNKNOWN_HTTP_METHOD, [$method]); - throw new LogicException($message); + throw Exception::create(self::ERROR_UNKNOWN_HTTP_METHOD, [$method]); break; } @@ -303,7 +309,7 @@ private function handleSparqlUpdate(Response $response, string $path, $contents) foreach ($values as $value) { $count = $graph->delete($resource, $property, $value); if ($count === 0) { - throw new \Exception("Could not delete a value", 500); + throw new Exception("Could not delete a value", 500); } } } @@ -311,7 +317,7 @@ private function handleSparqlUpdate(Response $response, string $path, $contents) } break; default: - throw new \Exception("Unimplemented SPARQL", 500); + throw new Exception("Unimplemented SPARQL", 500); break; } } @@ -352,8 +358,29 @@ private function handleCreateRequest(Response $response, string $path, $contents $response->getBody()->write($message); $response = $response->withStatus(400); } else { - // @FIXME: Handle error scenarios correctly (for instance trying to create a file underneath another file) - $success = $filesystem->write($path, $contents); + $success = false; + + set_error_handler(static function ($severity, $message, $filename, $line) { + throw new \ErrorException($message, 0, $severity, $filename, $line); + }); + + try { + $success = $filesystem->write($path, $contents); + } catch (FileExistsException $e) { + $message = vsprintf(self::ERROR_PUT_EXISTING_RESOURCE, [$path]); + $response->getBody()->write($message); + + return $response->withStatus(400); + } catch (Throwable $exception) { + /*/ An error occurred in the underlying flysystem adapter /*/ + $message = vsprintf('Could not write to path %s: %s', [$path, $exception->getMessage()]); + $response->getBody()->write($message); + + return $response->withStatus(400); + } finally { + restore_error_handler(); + } + if ($success) { $response = $response->withHeader("Location", $this->baseUrl . $path); $response = $response->withStatus(201); @@ -413,12 +440,17 @@ private function sendWebsocketUpdate($path) 'Sec-WebSocket-Protocol' => 'solid-0.1' ) )); - $client->send("pub $baseUrl$path\n"); - while ($path !== "/") { - $path = $this->parentPath($path); - $client->send("pub $baseUrl$path\n"); - } + try { + $client->send("pub $baseUrl$path\n"); + + while ($path !== "/") { + $path = $this->parentPath($path); + $client->send("pub $baseUrl$path\n"); + } + } catch (\WebSocket\Exception $exception) { + throw new Exception('Could not write to pub-sup server', 502, $exception); + } } private function handleDeleteRequest(Response $response, string $path, $contents): Response @@ -500,12 +532,13 @@ private function getRequestedMimeType($accept) private function handleReadRequest(Response $response, string $path, $contents, $mime=''): Response { $filesystem = $this->filesystem; + if ($path === "/") { // FIXME: this is a patch to make it work for Solid-Nextcloud; we should be able to just list '/'; $contents = $this->listDirectoryAsTurtle($path); $response->getBody()->write($contents); $response = $response->withHeader("Content-type", "text/turtle"); $response = $response->withStatus(200); - } else if ($filesystem->has($path) === false) { + } elseif ($filesystem->has($path) === false) { $message = vsprintf(self::ERROR_PATH_DOES_NOT_EXIST, [$path]); $response->getBody()->write($message); $response = $response->withStatus(404); @@ -518,8 +551,10 @@ private function handleReadRequest(Response $response, string $path, $contents, $response = $response->withStatus(200); } else { if ($filesystem->asMime($mime)->has($path)) { - $mimetype = $filesystem->asMime($mime)->getMimetype($path); $contents = $filesystem->asMime($mime)->read($path); + + $response = $this->addLinkRelationHeaders($response, $path, $mime); + if (preg_match('/.ttl$/', $path)) { $mimetype = "text/turtle"; // FIXME: teach flysystem that .ttl means text/turtle } elseif (preg_match('/.acl$/', $path)) { @@ -576,11 +611,17 @@ private function listDirectoryAsTurtle($path) foreach ($listContents as $item) { switch($item['type']) { case "file": - $filename = "<" . rawurlencode($item['basename']) . ">"; - $turtle[$filename] = array( - "a" => array("ldp:Resource") - ); - $turtle["<>"]['ldp:contains'][] = $filename; + // ACL and meta files should not be listed in directory overview + if ( + $item['basename'] !== '.meta' + && in_array($item['extension'], ['acl', 'meta']) === false + ) { + $filename = "<" . rawurlencode($item['basename']) . ">"; + $turtle[$filename] = array( + "a" => array("ldp:Resource") + ); + $turtle["<>"]['ldp:contains'][] = $filename; + } break; case "dir": // FIXME: we have a trailing slash here to please the test suits, but it probably should also pass without it since we are a Container. @@ -591,7 +632,7 @@ private function listDirectoryAsTurtle($path) $turtle["<>"]['ldp:contains'][] = $filename; break; default: - throw new \Exception("Unknown type", 500); + throw new Exception("Unknown type", 500); break; } } @@ -617,4 +658,80 @@ private function listDirectoryAsTurtle($path) return $container; } + + // ========================================================================= + // @TODO: All Auxiliary Resources logic should probably be moved to a separate class. + + /** + * Currently, in the spec channel, it is under consideration to use + * or + * instead of (or besides) "acl" and + * instead of (or besides) "describedby". + * + * @see https://github.com/solid/specification/issues/172 + */ + private function addLinkRelationHeaders(Response $response, string $path, $mime=null): Response + { + // @FIXME: If a `.meta` file is requested, it must have header `Link: ; rel="describes"` + + if ($this->hasAcl($path, $mime)) { + $value = sprintf('<%s>; rel="acl"', $this->getDescribedByPath($path, $mime)); + $response = $response->withAddedHeader('Link', $value); + } + + if ($this->hasDescribedBy($path, $mime)) { + $value = sprintf('<%s>; rel="describedby"', $this->getDescribedByPath($path, $mime)); + $response = $response->withAddedHeader('Link', $value); + } + + return $response; + } + + private function getAclPath(string $path, $mime = null): string + { + $metadataCache = $this->getMetadata($path, $mime); + + return $metadataCache[$path]['acl'] ?? ''; + } + + private function getDescribedByPath(string $path, $mime = null): string + { + $metadataCache = $this->getMetadata($path, $mime); + + return $metadataCache[$path]['describedby'] ?? ''; + } + + private function getMetadata(string $path, $mime) : array + { + // @NOTE: Because the lookup can be expensive, we cache the result + static $metadataCache = []; + + if (isset($metadataCache[$path]) === false) { + $filesystem = $this->filesystem; + + try { + if ($mime) { + $metadata = $filesystem->asMime($mime)->getMetadata($path); + } else { + $metadata = $filesystem->getMetadata($path); + } + } catch (FileNotFoundException $e) { + $metadata = []; + } + + $metadataCache[$path . $mime] = $metadata; + } + + return $metadataCache; + } + + private function hasAcl(string $path, $mime = null): bool + { + return $this->getAclPath($path, $mime) !== ''; + } + + private function hasDescribedBy(string $path, $mime = null): bool + { + return $this->getDescribedByPath($path, $mime) !== ''; + } } diff --git a/src/example.php b/src/example.php index 0b6b3ee..2c8a5bc 100644 --- a/src/example.php +++ b/src/example.php @@ -66,7 +66,10 @@ $response = $server->respondToRequest($request); } elseif ($target === 'GET/') { - $response->getBody()->write(getHomepage()); + $fileHandle = fopen(__FILE__, 'rb'); + fseek($fileHandle, __COMPILER_HALT_OFFSET__); + $homepage = stream_get_contents($fileHandle); + $response->getBody()->write($homepage); } else { $response = $response->withStatus(404); $response->getBody()->write("

404

Path '$path' does not exist.

"); @@ -85,18 +88,9 @@ } } -echo (string) $response->getBody(); +echo (string) $response->getBody(); exit; -function getHomepage() : string -{ - $fileHandle = fopen(__FILE__, 'rb'); - - fseek($fileHandle, __COMPILER_HALT_OFFSET__); - - return stream_get_contents($fileHandle); -} - __halt_compiler(); diff --git a/tests/fixtures/.acl b/tests/fixtures/.acl new file mode 100644 index 0000000..88bd589 --- /dev/null +++ b/tests/fixtures/.acl @@ -0,0 +1 @@ +# Empty ACL file diff --git a/tests/fixtures/file.ttl b/tests/fixtures/file.ttl new file mode 100644 index 0000000..ede8e19 --- /dev/null +++ b/tests/fixtures/file.ttl @@ -0,0 +1,6 @@ +@prefix dc: . +@prefix rdfs: . + + + dc:title "Top-level Test document" ; + rdfs:comment "Dummy file for testing metadata file in same directory" . diff --git a/tests/fixtures/nested/parent/child/file.ttl b/tests/fixtures/nested/parent/child/file.ttl new file mode 100644 index 0000000..1d44774 --- /dev/null +++ b/tests/fixtures/nested/parent/child/file.ttl @@ -0,0 +1,6 @@ +@prefix dc: . +@prefix rdfs: . + + + dc:title "Nested Test document" ; + rdfs:comment "Dummy file for testing metadata file in a parent directory" .