From e124d2975f86a8a243030576336cf3a52564fa25 Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Sat, 7 Feb 2026 00:25:39 +0100 Subject: [PATCH 01/10] add mini-storag-server for user profiles so they can be edited by the user --- config.php.example | 1 + lib/ProfileServer.php | 161 ++++++++++++++++++++++++++++++++ lib/Routes/SolidUserProfile.php | 95 +++++++++++-------- 3 files changed, 220 insertions(+), 37 deletions(-) create mode 100644 lib/ProfileServer.php diff --git a/config.php.example b/config.php.example index 32460bb..4cba4f4 100644 --- a/config.php.example +++ b/config.php.example @@ -39,6 +39,7 @@ ]; const STORAGEBASE = __DIR__ . "/pods/"; + const PROFILEBASE = __DIR__ . "/profiles/"; const PUBSUB_SERVER = "wss://pubsub:8080"; diff --git a/lib/ProfileServer.php b/lib/ProfileServer.php new file mode 100644 index 0000000..06fd37a --- /dev/null +++ b/lib/ProfileServer.php @@ -0,0 +1,161 @@ +addPlugin(new \Pdsinterop\Rdf\Flysystem\Plugin\AsMime($formats)); + $plugin = new \Pdsinterop\Rdf\Flysystem\Plugin\ReadRdf($graph); + $filesystem->addPlugin($plugin); + return $filesystem; + } + + public static function respond($response) { + $statusCode = $response->getStatusCode(); + $response->getBody()->rewind(); + $headers = $response->getHeaders(); + + $body = $response->getBody()->getContents(); + header("HTTP/1.1 $statusCode"); + foreach ($headers as $header => $values) { + foreach ($values as $value) { + if ($header == "Location") { + $value = preg_replace("|%26%2334%3B|", "%22", $value); // odoo weird encoding + } + header($header . ":" . $value); + } + } + echo $body; + } + + public static function getWebId($rawRequest) { + $dpop = self::getDpop(); + $webId = $dpop->getWebId($rawRequest); + if (!isset($webId)) { + $bearer = self::getBearer(); + $webId = $bearer->getWebId($rawRequest); + } + return $webId; + } + + private static function getProfileId() { + $serverName = Util::getServerName(); + $idParts = explode(".", $serverName, 2); + $profileId = preg_replace("/^id-/", "", $idParts[0]); + return $profileId; + } + + public static function getOwner() { + $profileId = self::getProfileId(); + return User::getUserById($profileId); + } + + public static function getOwnerWebId() { + $owner = self::getOwner(); + return $owner['webId']; + } + + public static function initializeProfile() { + $filesystem = self::getFilesystem(); + if (!$filesystem->has("/.acl")) { + $defaultAcl = self::generateDefaultAcl(); + $filesystem->write("/.acl", $defaultAcl); + } + + // Generate default folders and ACLs: + if (!$filesystem->has("/profile.ttl")) { + $profile = self::generateDefaultProfile(); + $filesystem->write("/profile.ttl", $profile); + } + } + + public static function generateDefaultAcl() { + $webId = self::getOwnerWebId(); + $acl = <<< "EOF" +# Root ACL resource for the user account +@prefix acl: . +@prefix foaf: . + +# The homepage is readable by the public +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:accessTo <./>; + acl:mode acl:Read. + +# The owner has full access to every resource in their pod. +# Other agents have no access rights, +# unless specifically authorized in other .acl resources. +<#owner> + a acl:Authorization; + acl:agent <$webId>; + # Set the access to the root storage folder itself + acl:accessTo <./>; + # All resources will inherit this authorization, by default + acl:default <./>; + # The owner has all of the access modes allowed + acl:mode + acl:Read, acl:Write, acl:Control. +EOF; + return $acl; + } + + public static function generateDefaultProfile() { + $user = self::getOwner(); + if (!isset($user['storage']) || !$user['storage']) { + $user['storage'] = "https://storage-" . $userId . "." . BASEDOMAIN . "/"; + } + if (is_array($user['storage'])) { // empty array is already handled + $user['storage'] = array_values($user['storage'])[0]; // FIXME: Handle multiple storage pods + } + if (!isset($user['issuer'])) { + $user['issuer'] = BASEURL; + } + + $profile = <<< "EOF" +@prefix : <#>. +@prefix acl: . +@prefix foaf: . +@prefix ldp: . +@prefix schema: . +@prefix solid: . +@prefix space: . +@prefix vcard: . +@prefix pro: <./>. +@prefix inbox: <{$user['storage']}inbox/>. + +<> a foaf:PersonalProfileDocument; foaf:maker :me; foaf:primaryTopic :me. + +:me + a schema:Person, foaf:Person; + ldp:inbox inbox:; + space:preferencesFile <{$user['storage']}settings/preferences.ttl>; + space:storage <{$user['storage']}>; + solid:account <{$user['storage']}>; + solid:oidcIssuer <{$user['issuer']}>; + solid:privateTypeIndex <{$user['storage']}settings/privateTypeIndex.ttl>; + solid:publicTypeIndex <{$user['storage']}settings/publicTypeIndex.ttl>. +EOF; + return $profile; + } + } diff --git a/lib/Routes/SolidUserProfile.php b/lib/Routes/SolidUserProfile.php index 78ae9ac..ac5440a 100644 --- a/lib/Routes/SolidUserProfile.php +++ b/lib/Routes/SolidUserProfile.php @@ -1,52 +1,73 @@ fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES); - $user = User::getUserById($userId); - if (!isset($user['storage']) || !$user['storage']) { - $user['storage'] = "https://storage-" . $userId . "." . BASEDOMAIN . "/"; + ProfileServer::initializeStorage(); + $filesystem = ProfileServer::getFileSystem(); + + $resourceServer = new ResourceServer($filesystem, new Response(), null); + $solidNotifications = new SolidNotifications(); + $resourceServer->setNotifications($solidNotifications); + + $wac = new WAC($filesystem); + + $baseUrl = Util::getServerBaseUrl(); + $resourceServer->setBaseUrl($baseUrl); + $wac->setBaseUrl($baseUrl); + + $webId = ProfileServer::getWebId($rawRequest); + + if (!isset($webId)) { + $response = $resourceServer->getResponse() + ->withStatus(409, "Invalid token"); + ProfileServer::respond($response); + exit(); } - if (is_array($user['storage'])) { // empty array is already handled - $user['storage'] = array_values($user['storage'])[0]; // FIXME: Handle multiple storage pods + + $origin = $rawRequest->getHeaderLine("Origin"); + + // FIXME: Read allowed clients from the profile instead; + $owner = ProfileServer::getOwner(); + + $allowedClients = $owner['allowedClients'] ?? []; + $allowedOrigins = []; + foreach ($allowedClients as $clientId) { + $clientRegistration = ClientRegistration::getRegistration($clientId); + if (isset($clientRegistration['client_name'])) { + $allowedOrigins[] = $clientRegistration['client_name']; + } + if (isset($clientRegistration['origin'])) { + $allowedOrigins[] = $clientRegistration['origin']; + } } - if (!isset($user['issuer'])) { - $user['issuer'] = BASEURL; + if (!isset($origin) || ($origin === "")) { + $allowedOrigins[] = "app://unset"; // FIXME: this should not be here. + $origin = "app://unset"; + } + + if (!$wac->isAllowed($rawRequest, $webId, $origin, $allowedOrigins)) { + $response = new Response(); + $response = $response->withStatus(403, "Access denied!"); + ProfileServer::respond($response); + exit(); } - $profile = <<<"EOF" -@prefix : <#>. -@prefix acl: . -@prefix foaf: . -@prefix ldp: . -@prefix schema: . -@prefix solid: . -@prefix space: . -@prefix vcard: . -@prefix pro: <./>. -@prefix inbox: <{$user['storage']}inbox/>. - -<> a foaf:PersonalProfileDocument; foaf:maker :me; foaf:primaryTopic :me. - -:me - a schema:Person, foaf:Person; - ldp:inbox inbox:; - space:preferencesFile <{$user['storage']}settings/prefs.ttl>; - space:storage <{$user['storage']}>; - solid:account <{$user['storage']}>; - solid:oidcIssuer <{$user['issuer']}>; - solid:privateTypeIndex <{$user['storage']}settings/privateTypeIndex.ttl>; - solid:publicTypeIndex <{$user['storage']}settings/publicTypeIndex.ttl>. -EOF; - header('Content-Type: text/turtle'); - echo $profile; + $response = $resourceServer->respondToRequest($rawRequest); + $response = $wac->addWACHeaders($rawRequest, $response, $webId); + ProfileServer::respond($response); } } \ No newline at end of file From a9e2c8119ead2e062e9bb27a5d24e4ea18feacb0 Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Sat, 7 Feb 2026 01:00:47 +0100 Subject: [PATCH 02/10] hardcode request to be profile.ttl --- lib/Routes/SolidUserProfile.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/Routes/SolidUserProfile.php b/lib/Routes/SolidUserProfile.php index ac5440a..bcaad7d 100644 --- a/lib/Routes/SolidUserProfile.php +++ b/lib/Routes/SolidUserProfile.php @@ -13,9 +13,11 @@ class SolidUserProfile { public static function respondToProfile() { $requestFactory = new ServerRequestFactory(); - $rawRequest = $requestFactory->fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES); + $serverData = $_SERVER; + $serverData['REQUEST_URI'] = "/profile.ttl"; // Hardcoded so we can only ever return profile.ttl - ProfileServer::initializeStorage(); + $rawRequest = $requestFactory->fromGlobals($serverData, $_GET, $_POST, $_COOKIE, $_FILES); + ProfileServer::initializeProfile(); $filesystem = ProfileServer::getFileSystem(); $resourceServer = new ResourceServer($filesystem, new Response(), null); From 889571b4494ad02d613aef64c0f830143ad4a445 Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Sat, 7 Feb 2026 01:01:06 +0100 Subject: [PATCH 03/10] allow read, fix userId --- lib/ProfileServer.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/ProfileServer.php b/lib/ProfileServer.php index 06fd37a..e73b166 100644 --- a/lib/ProfileServer.php +++ b/lib/ProfileServer.php @@ -101,7 +101,9 @@ public static function generateDefaultAcl() { a acl:Authorization; acl:agentClass foaf:Agent; acl:accessTo <./>; - acl:mode acl:Read. + # All resources will inherit this authorization, by default + acl:default <./>; + acl:mode acl:Read. # The owner has full access to every resource in their pod. # Other agents have no access rights, @@ -123,7 +125,7 @@ public static function generateDefaultAcl() { public static function generateDefaultProfile() { $user = self::getOwner(); if (!isset($user['storage']) || !$user['storage']) { - $user['storage'] = "https://storage-" . $userId . "." . BASEDOMAIN . "/"; + $user['storage'] = "https://storage-" . self::getProfileId() . "." . BASEDOMAIN . "/"; } if (is_array($user['storage'])) { // empty array is already handled $user['storage'] = array_values($user['storage'])[0]; // FIXME: Handle multiple storage pods From ebb9ff43ea0fde8d8c6fde43af80fa778dfbb13e Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Sat, 7 Feb 2026 01:01:23 +0100 Subject: [PATCH 04/10] allow GET and PUT --- www/user/profile.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/www/user/profile.php b/www/user/profile.php index c96c561..a04ec1e 100644 --- a/www/user/profile.php +++ b/www/user/profile.php @@ -15,16 +15,14 @@ switch($method) { case "GET": - switch ($request) { - case "/": - SolidUserProfile::respondToProfile(); - break; - } + case "PUT": + SolidUserProfile::respondToProfile(); break; case "OPTIONS": + echo "OK"; + return; break; case "POST": - case "PUT": default: header($_SERVER['SERVER_PROTOCOL'] . " 405 Method not allowed"); break; From d8ce511c85ccaeef25952234c6ea5611a306507e Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Sat, 7 Feb 2026 01:02:27 +0100 Subject: [PATCH 05/10] add profiles --- README.md | 2 +- docker-compose.yml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ef0ac7c..c577741 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Note: Update the values in the config.php file where needed befure running the i ```sh docker exec -w /opt/solid/ solid cp config.php.example config.php docker exec -u www-data -i -w /opt/solid/ solid php init.php -docker exec -w /opt/solid/ solid chown -R www-data:www-data keys pods db +docker exec -w /opt/solid/ solid chown -R www-data:www-data keys pods profiles db ``` ### DNS gotcha and snake oil certificate diff --git a/docker-compose.yml b/docker-compose.yml index 7c9872c..e2d00c1 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ services: - ./data/keys:/opt/solid/keys - ./data/db:/opt/solid/db - ./data/pods:/opt/solid/pods + - ./data/profiles:/opt/solid/profiles pubsub: build: context: https://github.com/pdsinterop/php-solid-pubsub-server.git#main From 44fc1def172b9046a696f250f9bbbe48ca585454 Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Sat, 7 Feb 2026 01:16:48 +0100 Subject: [PATCH 06/10] add profiles dir --- tests/testsuite/run-solid-test-suite.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/testsuite/run-solid-test-suite.sh b/tests/testsuite/run-solid-test-suite.sh index 9917402..d5b1f5b 100755 --- a/tests/testsuite/run-solid-test-suite.sh +++ b/tests/testsuite/run-solid-test-suite.sh @@ -33,8 +33,8 @@ function startSolidPhp { docker run -d --name "$1" --network-alias="id-alice.solid" --network-alias="storage-alice.solid" --network-alias="id-bob.solid" --network-alias="storage-bob.solid" --network=local "${2:-solid-php}" echo "Running init script for Solid PHP $1 ..." - docker exec -w /opt/solid/ "$1" mkdir keys pods db - docker exec -w /opt/solid/ "$1" chown -R www-data:www-data keys pods db + docker exec -w /opt/solid/ "$1" mkdir keys pods profiles db + docker exec -w /opt/solid/ "$1" chown -R www-data:www-data keys pods profiles db docker exec -w /opt/solid/ "$1" cp tests/testsuite/config.php.testsuite config.php docker exec -u www-data -i -w /opt/solid/ "$1" php init.php docker exec -u www-data -i -w /opt/solid/ "$1" php tests/testsuite/init-testsuite.php From 2a11f8dd80de7c594d61e519bea7820409a4df4a Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Sat, 7 Feb 2026 01:24:45 +0100 Subject: [PATCH 07/10] add profile base --- tests/testsuite/config.php.testsuite | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/testsuite/config.php.testsuite b/tests/testsuite/config.php.testsuite index 4883489..8ee9d0e 100644 --- a/tests/testsuite/config.php.testsuite +++ b/tests/testsuite/config.php.testsuite @@ -22,6 +22,7 @@ const PUBSUB_SERVER = "https://pubsub:8080"; const STORAGEBASE = __DIR__ . "/pods/"; + const PROFILEBASE = __DIR__ . "/profiles/"; const TRUSTED_IPS = []; const TRUSTED_APPS = ['https://tester', 'http://localhost:3002']; From 7999bb5c8627c25ab21455b6b1ea8e5e4fb2299c Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Sat, 7 Feb 2026 02:03:07 +0100 Subject: [PATCH 08/10] fix htu dpop for the test suite --- lib/Routes/SolidUserProfile.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/Routes/SolidUserProfile.php b/lib/Routes/SolidUserProfile.php index bcaad7d..080ab54 100644 --- a/lib/Routes/SolidUserProfile.php +++ b/lib/Routes/SolidUserProfile.php @@ -30,7 +30,8 @@ public static function respondToProfile() { $resourceServer->setBaseUrl($baseUrl); $wac->setBaseUrl($baseUrl); - $webId = ProfileServer::getWebId($rawRequest); + // use the original $_SERVER without modified path, otherwise the htu check for DPOP will fail + $webId = ProfileServer::getWebId($requestFactory->fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES)); if (!isset($webId)) { $response = $resourceServer->getResponse() From 7eeadf947b782a4868d7d2a0c622af1f7e468a1c Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Mon, 9 Feb 2026 16:41:53 +0100 Subject: [PATCH 09/10] allow PATCH requests --- www/user/profile.php | 1 + 1 file changed, 1 insertion(+) diff --git a/www/user/profile.php b/www/user/profile.php index a04ec1e..aaaa76b 100644 --- a/www/user/profile.php +++ b/www/user/profile.php @@ -16,6 +16,7 @@ switch($method) { case "GET": case "PUT": + case "PATCH": SolidUserProfile::respondToProfile(); break; case "OPTIONS": From 4ba6b2b0f6e262e827434ad3faa0e8ddc90e71ec Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Tue, 10 Feb 2026 01:15:09 +0100 Subject: [PATCH 10/10] use lockToPath instead --- lib/Routes/SolidUserProfile.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Routes/SolidUserProfile.php b/lib/Routes/SolidUserProfile.php index 080ab54..287c7f8 100644 --- a/lib/Routes/SolidUserProfile.php +++ b/lib/Routes/SolidUserProfile.php @@ -14,9 +14,8 @@ class SolidUserProfile { public static function respondToProfile() { $requestFactory = new ServerRequestFactory(); $serverData = $_SERVER; - $serverData['REQUEST_URI'] = "/profile.ttl"; // Hardcoded so we can only ever return profile.ttl - $rawRequest = $requestFactory->fromGlobals($serverData, $_GET, $_POST, $_COOKIE, $_FILES); + $rawRequest = $requestFactory->fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES); ProfileServer::initializeProfile(); $filesystem = ProfileServer::getFileSystem(); @@ -28,6 +27,7 @@ public static function respondToProfile() { $baseUrl = Util::getServerBaseUrl(); $resourceServer->setBaseUrl($baseUrl); + $resourceServer->lockToPath("/profile.ttl"); $wac->setBaseUrl($baseUrl); // use the original $_SERVER without modified path, otherwise the htu check for DPOP will fail