@fleetbase/solid-engine 0.0.4 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/ACL_SOLUTION.md +72 -0
  2. package/CSS_SCOPE_ISSUE.md +140 -0
  3. package/HOTFIX_SYNTAX_ERROR.md +100 -0
  4. package/MANUAL_ACL_SETUP.md +135 -0
  5. package/REFACTORING_SUMMARY.md +330 -0
  6. package/VERIFICATION_CHECKLIST.md +82 -0
  7. package/addon/components/modals/create-solid-folder.hbs +29 -0
  8. package/addon/components/modals/import-solid-resources.hbs +85 -0
  9. package/addon/controllers/data/content.js +17 -0
  10. package/addon/controllers/data/index.js +219 -0
  11. package/addon/controllers/home.js +84 -0
  12. package/addon/engine.js +1 -24
  13. package/addon/extension.js +26 -0
  14. package/addon/routes/data/content.js +11 -0
  15. package/addon/routes/data/index.js +17 -0
  16. package/addon/routes.js +2 -7
  17. package/addon/styles/solid-engine.css +1 -2
  18. package/addon/templates/account.hbs +3 -3
  19. package/addon/templates/application.hbs +2 -12
  20. package/addon/templates/data/content.hbs +48 -0
  21. package/addon/templates/{pods/explorer.hbs → data/index.hbs} +6 -5
  22. package/addon/templates/home.hbs +168 -10
  23. package/app/components/modals/{backup-pod.js → create-solid-folder.js} +1 -1
  24. package/app/components/modals/{resync-pod.js → import-solid-resources.js} +1 -1
  25. package/app/components/modals/{create-pod.js → setup-css-credentials.js} +1 -1
  26. package/composer.json +4 -10
  27. package/extension.json +1 -1
  28. package/index.js +0 -11
  29. package/package.json +8 -8
  30. package/server/migrations/2024_12_21_add_css_credentials_to_solid_identities_table.php +32 -0
  31. package/server/src/Client/OpenIDConnectClient.php +686 -15
  32. package/server/src/Client/SolidClient.php +104 -8
  33. package/server/src/Http/Controllers/DataController.php +261 -0
  34. package/server/src/Http/Controllers/OIDCController.php +42 -8
  35. package/server/src/Http/Controllers/SolidController.php +179 -85
  36. package/server/src/Models/SolidIdentity.php +13 -3
  37. package/server/src/Services/AclService.php +146 -0
  38. package/server/src/Services/PodService.php +863 -0
  39. package/server/src/Services/ResourceSyncService.php +336 -0
  40. package/server/src/Services/VehicleSyncService.php +289 -0
  41. package/server/src/Support/Utils.php +10 -0
  42. package/server/src/routes.php +25 -1
  43. package/addon/components/modals/backup-pod.hbs +0 -3
  44. package/addon/components/modals/create-pod.hbs +0 -5
  45. package/addon/components/modals/resync-pod.hbs +0 -3
  46. package/addon/controllers/pods/explorer/content.js +0 -12
  47. package/addon/controllers/pods/explorer.js +0 -149
  48. package/addon/controllers/pods/index/pod.js +0 -12
  49. package/addon/controllers/pods/index.js +0 -137
  50. package/addon/routes/pods/explorer/content.js +0 -10
  51. package/addon/routes/pods/explorer.js +0 -44
  52. package/addon/routes/pods/index/pod.js +0 -3
  53. package/addon/routes/pods/index.js +0 -21
  54. package/addon/templates/pods/explorer/content.hbs +0 -19
  55. package/addon/templates/pods/index/pod.hbs +0 -11
  56. package/addon/templates/pods/index.hbs +0 -19
  57. package/server/src/LegacyClient/Identity/IdentityProvider.php +0 -174
  58. package/server/src/LegacyClient/Identity/Profile.php +0 -18
  59. package/server/src/LegacyClient/OIDCClient.php +0 -350
  60. package/server/src/LegacyClient/Profile/WebID.php +0 -26
  61. package/server/src/LegacyClient/SolidClient.php +0 -271
@@ -0,0 +1,863 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\Solid\Services;
4
+
5
+ use Fleetbase\Solid\Client\SolidClient;
6
+ use Fleetbase\Solid\Models\SolidIdentity;
7
+ use Illuminate\Support\Facades\Log;
8
+ use Illuminate\Support\Str;
9
+
10
+ class PodService
11
+ {
12
+ /**
13
+ * Create a new pod with 401 error handling.
14
+ */
15
+ public function createPod(SolidIdentity $identity, string $name, ?string $description = null): array
16
+ {
17
+ try {
18
+ // Get profile data
19
+ $profile = $this->getProfileData($identity);
20
+ $webId = $profile['webid'];
21
+
22
+ // Extract storage location from WebID
23
+ $storageUrl = $this->getStorageUrlFromWebId($webId);
24
+
25
+ Log::info('[INFERRED STORAGE FROM WEBID]', [
26
+ 'webid' => $webId,
27
+ 'storage_url' => $storageUrl,
28
+ ]);
29
+
30
+ // Test the storage location first
31
+ $isValidStorage = $this->testStorageLocation($identity, $storageUrl);
32
+
33
+ if (!$isValidStorage) {
34
+ throw new \Exception("Storage location {$storageUrl} is not accessible. Please check your permissions.");
35
+ }
36
+
37
+ // Create the pod with multiple methods
38
+ return $this->createPodInStorage($identity, $storageUrl, $name, $description);
39
+ } catch (\Throwable $e) {
40
+ Log::error('[CREATE POD ERROR]', [
41
+ 'name' => $name,
42
+ 'error' => $e->getMessage(),
43
+ 'trace' => $e->getTraceAsString(),
44
+ ]);
45
+ throw $e;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Extract storage URL from WebID.
51
+ */
52
+ private function getStorageUrlFromWebId(string $webId): string
53
+ {
54
+ $parsed = parse_url($webId);
55
+ $baseUrl = $parsed['scheme'] . '://' . $parsed['host'];
56
+
57
+ if (isset($parsed['port'])) {
58
+ $baseUrl .= ':' . $parsed['port'];
59
+ }
60
+
61
+ // Extract username from path like /test/profile/card#me
62
+ $path = $parsed['path'];
63
+ if (preg_match('/^\/([^\/]+)\//', $path, $matches)) {
64
+ $username = $matches[1];
65
+
66
+ return $baseUrl . '/' . $username . '/';
67
+ }
68
+
69
+ return $baseUrl . '/';
70
+ }
71
+
72
+ /**
73
+ * Test if storage location is accessible.
74
+ */
75
+ private function testStorageLocation(SolidIdentity $identity, string $storageUrl): bool
76
+ {
77
+ try {
78
+ $response = $identity->request('head', $storageUrl);
79
+
80
+ Log::info('[STORAGE TEST]', [
81
+ 'storage_url' => $storageUrl,
82
+ 'status' => $response->status(),
83
+ 'headers' => $response->headers(),
84
+ ]);
85
+
86
+ if (!$response->successful()) {
87
+ return false;
88
+ }
89
+
90
+ // Parse WAC-Allow for user rights
91
+ $wac = $response->header('WAC-Allow');
92
+ if ($wac && preg_match('/user="([^"]+)"/', $wac, $m)) {
93
+ $perms = $m[1]; // e.g., read write append control
94
+ $hasWrite = str_contains($perms, 'write') || str_contains($perms, 'append');
95
+ if (!$hasWrite) {
96
+ Log::warning('[STORAGE NOT WRITABLE]', ['wac_allow' => $wac]);
97
+ // Still return true so we can attempt creation – CSS sometimes omits full perms here,
98
+ // but the 401 will be handled and logged later if writes are truly blocked.
99
+ }
100
+ }
101
+
102
+ return true;
103
+ } catch (\Throwable $e) {
104
+ Log::error('[STORAGE TEST FAILED]', ['storage_url' => $storageUrl, 'error' => $e->getMessage()]);
105
+
106
+ return false;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Create pod in storage location with multiple methods.
112
+ */
113
+ private function createPodInStorage(SolidIdentity $identity, string $storageUrl, string $name, ?string $description = null): array
114
+ {
115
+ $podSlug = Str::slug($name);
116
+ $podUrl = rtrim($storageUrl, '/') . '/' . $podSlug . '/';
117
+
118
+ Log::info('[CREATING POD]', ['name' => $name, 'storage_url' => $storageUrl, 'pod_url' => $podUrl]);
119
+
120
+ // Check if identity has CSS credentials for account management API
121
+ $cssAccountService = app(CssAccountService::class);
122
+
123
+ if ($cssAccountService->hasCredentials($identity)) {
124
+ Log::info('[USING CSS ACCOUNT MANAGEMENT API]');
125
+
126
+ try {
127
+ // Get WebID and extract issuer from it
128
+ $tokenResponse = $identity->token_response;
129
+ $idToken = data_get($tokenResponse, 'id_token');
130
+
131
+ if (!$idToken) {
132
+ throw new \Exception('No ID token available');
133
+ }
134
+
135
+ $solid = SolidClient::create(['identity' => $identity]);
136
+ $webId = $solid->oidc->getWebIdFromIdToken($idToken);
137
+
138
+ if (!$webId) {
139
+ throw new \Exception('Could not extract WebID from ID token');
140
+ }
141
+
142
+ // Extract issuer from WebID URL
143
+ $parsed = parse_url($webId);
144
+ $issuer = $parsed['scheme'] . '://' . $parsed['host'];
145
+ if (isset($parsed['port'])) {
146
+ $issuer .= ':' . $parsed['port'];
147
+ }
148
+
149
+ // Use email/password login to get CSS-Account-Token for pod management
150
+ $email = $identity->css_email;
151
+ $password = decrypt($identity->css_password);
152
+
153
+ Log::info('[CSS POD CREATION] Logging in with email/password for pod management');
154
+
155
+ $authorization = $cssAccountService->login($issuer, $email, $password);
156
+
157
+ if (!$authorization) {
158
+ throw new \Exception('Failed to login to CSS account for pod creation');
159
+ }
160
+
161
+ // Get the account API controls to find the pod creation endpoint
162
+ $controlsResponse = \Illuminate\Support\Facades\Http::withHeaders([
163
+ 'Authorization' => "CSS-Account-Token {$authorization}",
164
+ ])->get("{$issuer}/.account/");
165
+
166
+ if (!$controlsResponse->successful()) {
167
+ throw new \Exception('Failed to get account controls');
168
+ }
169
+
170
+ $controls = $controlsResponse->json();
171
+
172
+ Log::info('[CSS ACCOUNT CONTROLS]', ['controls' => $controls]);
173
+
174
+ $podControlUrl = data_get($controls, 'controls.account.pod');
175
+
176
+ if (!$podControlUrl) {
177
+ Log::warning('[POD CONTROL URL NOT FOUND]', ['controls_keys' => array_keys($controls)]);
178
+ // Fall back to legacy methods
179
+ throw new \Exception('Pod control URL not found - falling back to legacy methods');
180
+ }
181
+
182
+ Log::info('[CSS POD CONTROL URL]', ['url' => $podControlUrl]);
183
+
184
+ // Use the account management API to create pod
185
+ $response = \Illuminate\Support\Facades\Http::withHeaders([
186
+ 'Authorization' => "CSS-Account-Token {$authorization}",
187
+ 'Content-Type' => 'application/json',
188
+ ])->post($podControlUrl, [
189
+ 'name' => $podSlug,
190
+ ]);
191
+
192
+ Log::info('[CSS ACCOUNT API RESPONSE]', [
193
+ 'status' => $response->status(),
194
+ 'body' => $response->body(),
195
+ ]);
196
+
197
+ if ($response->successful()) {
198
+ $podData = $response->json();
199
+
200
+ return [
201
+ 'id' => $podSlug,
202
+ 'name' => $name,
203
+ 'url' => $podData['pod'] ?? $podUrl,
204
+ 'description' => $description,
205
+ 'created_at' => now()->toISOString(),
206
+ 'type' => 'pod',
207
+ 'status' => 'created',
208
+ 'method' => 'css_account_api',
209
+ ];
210
+ }
211
+
212
+ Log::warning('[CSS ACCOUNT API FAILED]', [
213
+ 'status' => $response->status(),
214
+ 'error' => $response->body(),
215
+ ]);
216
+ } catch (\Throwable $e) {
217
+ Log::error('[CSS ACCOUNT API ERROR]', [
218
+ 'error' => $e->getMessage(),
219
+ 'trace' => $e->getTraceAsString(),
220
+ ]);
221
+ // Fall through to legacy methods
222
+ }
223
+ } else {
224
+ Log::info('[NO CSS CREDENTIALS - USING LEGACY METHODS]');
225
+ }
226
+
227
+ // Legacy methods as fallback
228
+ $metadata = $this->generatePodMetadata($name, $description);
229
+
230
+ // ---- Method 1: POST to parent (recommended) ----
231
+ try {
232
+ $response = $identity->request('post', $storageUrl, $metadata, [
233
+ 'headers' => [
234
+ 'Content-Type' => 'text/turtle',
235
+ 'Slug' => $podSlug,
236
+ 'Link' => '<http://www.w3.org/ns/ldp#BasicContainer>; rel="type"',
237
+ 'If-None-Match' => '*',
238
+ 'Prefer' => 'return=representation',
239
+ ],
240
+ ]);
241
+
242
+ Log::info('[POST WITH METADATA RESPONSE]', [
243
+ 'storage_url' => $storageUrl,
244
+ 'status' => $response->status(),
245
+ 'response' => $response->body(),
246
+ ]);
247
+
248
+ if ($response->successful() || in_array($response->status(), [201, 202, 204])) {
249
+ $location = $response->header('Location') ?: $podUrl;
250
+
251
+ return [
252
+ 'id' => $podSlug,
253
+ 'name' => $name,
254
+ 'url' => $location,
255
+ 'description' => $description,
256
+ 'created_at' => now()->toISOString(),
257
+ 'type' => 'container',
258
+ 'status' => 'created',
259
+ 'method' => 'post_metadata',
260
+ ];
261
+ }
262
+ } catch (\Throwable $e) {
263
+ Log::warning('[POST METADATA METHOD FAILED]', ['error' => $e->getMessage()]);
264
+ }
265
+
266
+ // ---- Method 2: POST to parent with empty body (server may auto-assign metadata) ----
267
+ try {
268
+ $response = $identity->request('post', $storageUrl, '', [
269
+ 'headers' => [
270
+ 'Content-Type' => 'text/turtle',
271
+ 'Slug' => $podSlug,
272
+ 'Link' => '<http://www.w3.org/ns/ldp#BasicContainer>; rel="type"',
273
+ 'If-None-Match' => '*',
274
+ ],
275
+ ]);
276
+
277
+ Log::info('[POST EMPTY BODY RESPONSE]', [
278
+ 'storage_url' => $storageUrl,
279
+ 'status' => $response->status(),
280
+ 'response' => $response->body(),
281
+ ]);
282
+
283
+ if ($response->successful() || in_array($response->status(), [201, 202, 204])) {
284
+ $location = $response->header('Location') ?: $podUrl;
285
+
286
+ return [
287
+ 'id' => $podSlug,
288
+ 'name' => $name,
289
+ 'url' => $location,
290
+ 'description' => $description,
291
+ 'created_at' => now()->toISOString(),
292
+ 'type' => 'container',
293
+ 'status' => 'created',
294
+ 'method' => 'post_empty',
295
+ ];
296
+ }
297
+ } catch (\Throwable $e) {
298
+ Log::warning('[POST EMPTY METHOD FAILED]', ['error' => $e->getMessage()]);
299
+ }
300
+
301
+ // ---- Method 3: PUT directly to the container URL (fallback) ----
302
+ try {
303
+ $response = $identity->request('put', $podUrl, $metadata, [
304
+ 'headers' => [
305
+ 'Content-Type' => 'text/turtle',
306
+ 'Link' => '<http://www.w3.org/ns/ldp#BasicContainer>; rel="type"',
307
+ 'If-None-Match' => '*',
308
+ ],
309
+ ]);
310
+
311
+ Log::info('[PUT WITH METADATA RESPONSE]', [
312
+ 'pod_url' => $podUrl,
313
+ 'status' => $response->status(),
314
+ 'response' => $response->body(),
315
+ ]);
316
+
317
+ if ($response->successful() || in_array($response->status(), [201, 202, 204])) {
318
+ return [
319
+ 'id' => $podSlug,
320
+ 'name' => $name,
321
+ 'url' => $podUrl,
322
+ 'description' => $description,
323
+ 'created_at' => now()->toISOString(),
324
+ 'type' => 'container',
325
+ 'status' => 'created',
326
+ 'method' => 'put_metadata',
327
+ ];
328
+ }
329
+ } catch (\Throwable $e) {
330
+ Log::warning('[PUT METADATA METHOD FAILED]', ['error' => $e->getMessage()]);
331
+ }
332
+
333
+ // Final safety
334
+ throw new \Exception('All pod creation methods failed. If you have CSS credentials configured, there may be an issue with the account management API. Otherwise, please set up CSS credentials for pod creation.');
335
+ }
336
+
337
+ /**
338
+ * Get all pods for a user.
339
+ */
340
+ public function getUserPods(SolidIdentity $identity): array
341
+ {
342
+ try {
343
+ $profile = $this->getProfileData($identity);
344
+ $storageUrl = $this->getStorageUrlFromWebId($profile['webid']);
345
+ $webId = $profile['webid'];
346
+
347
+ $pods = [];
348
+
349
+ // Try to get pods from CSS Account Management API first
350
+ $cssAccountService = app(CssAccountService::class);
351
+ if ($cssAccountService->hasCredentials($identity)) {
352
+ try {
353
+ // Extract issuer from WebID
354
+ $parsed = parse_url($webId);
355
+ $issuer = $parsed['scheme'] . '://' . $parsed['host'];
356
+ if (isset($parsed['port'])) {
357
+ $issuer .= ':' . $parsed['port'];
358
+ }
359
+
360
+ $email = $identity->css_email;
361
+ $password = decrypt($identity->css_password);
362
+
363
+ $authorization = $cssAccountService->login($issuer, $email, $password);
364
+
365
+ if ($authorization) {
366
+ // Get account controls
367
+ $controlsResponse = \Illuminate\Support\Facades\Http::withHeaders([
368
+ 'Authorization' => "CSS-Account-Token {$authorization}",
369
+ ])->get("{$issuer}/.account/");
370
+
371
+ if ($controlsResponse->successful()) {
372
+ $controls = $controlsResponse->json();
373
+ $podControlUrl = data_get($controls, 'controls.account.pod');
374
+
375
+ if ($podControlUrl) {
376
+ // Get pods from account management API
377
+ $podsResponse = \Illuminate\Support\Facades\Http::withHeaders([
378
+ 'Authorization' => "CSS-Account-Token {$authorization}",
379
+ ])->get($podControlUrl);
380
+
381
+ if ($podsResponse->successful()) {
382
+ $podsData = $podsResponse->json();
383
+ $accountPods = data_get($podsData, 'pods', []);
384
+
385
+ Log::info('[CSS ACCOUNT PODS]', ['pods' => $accountPods]);
386
+
387
+ foreach ($accountPods as $podUrl => $accountUrl) {
388
+ $podName = $this->extractPodName($podUrl);
389
+ $pods[] = [
390
+ 'id' => Str::slug($podName),
391
+ 'name' => $podName,
392
+ 'url' => $podUrl,
393
+ 'type' => 'pod',
394
+ 'source' => 'css_account',
395
+ ];
396
+ }
397
+ }
398
+ }
399
+ }
400
+ }
401
+ } catch (\Throwable $e) {
402
+ Log::warning('[CSS ACCOUNT PODS FETCH WARNING]', [
403
+ 'error' => $e->getMessage(),
404
+ ]);
405
+ }
406
+ }
407
+
408
+ // Get the main storage pod
409
+ try {
410
+ $podResponse = $identity->request('get', $storageUrl);
411
+ if ($podResponse->successful()) {
412
+ $podData = $this->parsePodData($storageUrl, $podResponse->body());
413
+ $pods[] = $podData;
414
+ }
415
+ } catch (\Throwable $e) {
416
+ Log::warning('[STORAGE POD FETCH WARNING]', [
417
+ 'storage_url' => $storageUrl,
418
+ 'error' => $e->getMessage(),
419
+ ]);
420
+ }
421
+
422
+ // Get containers within storage
423
+ $containers = $this->getContainers($identity, [$storageUrl]);
424
+ $pods = array_merge($pods, $containers);
425
+
426
+ return $pods;
427
+ } catch (\Throwable $e) {
428
+ Log::error('[GET USER PODS ERROR]', [
429
+ 'error' => $e->getMessage(),
430
+ ]);
431
+ throw $e;
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Get pod contents.
437
+ */
438
+ public function getPodContents(SolidIdentity $identity, string $podIdOrUrl): array
439
+ {
440
+ try {
441
+ // If it's a URL (starts with http), use it directly
442
+ // Otherwise, look it up as a pod ID (for backward compatibility)
443
+ if (str_starts_with($podIdOrUrl, 'http://') || str_starts_with($podIdOrUrl, 'https://')) {
444
+ $podUrl = $podIdOrUrl;
445
+ } else {
446
+ // Find the pod URL by ID (old multi-pod architecture)
447
+ $pods = $this->getUserPods($identity);
448
+ $pod = collect($pods)->firstWhere('id', $podIdOrUrl);
449
+
450
+ if (!$pod) {
451
+ throw new \Exception('Pod not found');
452
+ }
453
+
454
+ $podUrl = $pod['url'];
455
+ }
456
+
457
+ $response = $identity->request('get', $podUrl);
458
+
459
+ if (!$response->successful()) {
460
+ throw new \Exception('Failed to fetch pod contents');
461
+ }
462
+
463
+ return $this->parseContainerContents($response->body());
464
+ } catch (\Throwable $e) {
465
+ Log::error('[GET POD CONTENTS ERROR]', [
466
+ 'pod_id_or_url' => $podIdOrUrl,
467
+ 'error' => $e->getMessage(),
468
+ ]);
469
+ throw $e;
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Delete a pod.
475
+ */
476
+ public function deletePod(SolidIdentity $identity, string $podId): bool
477
+ {
478
+ try {
479
+ // Find the pod URL by ID
480
+ $pods = $this->getUserPods($identity);
481
+ $pod = collect($pods)->firstWhere('id', $podId);
482
+
483
+ if (!$pod) {
484
+ throw new \Exception('Pod not found');
485
+ }
486
+
487
+ $podUrl = $pod['url'];
488
+
489
+ // Delete the pod container
490
+ $response = $identity->request('delete', $podUrl);
491
+
492
+ $success = $response->successful();
493
+
494
+ Log::info('[POD DELETED]', [
495
+ 'pod_id' => $podId,
496
+ 'url' => $podUrl,
497
+ 'success' => $success,
498
+ ]);
499
+
500
+ return $success;
501
+ } catch (\Throwable $e) {
502
+ Log::error('[DELETE POD ERROR]', [
503
+ 'pod_id' => $podId,
504
+ 'error' => $e->getMessage(),
505
+ ]);
506
+ throw $e;
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Get profile data.
512
+ */
513
+ public function getProfileData(SolidIdentity $identity): array
514
+ {
515
+ $tokenResponse = $identity->token_response;
516
+ $idToken = data_get($tokenResponse, 'id_token');
517
+ if (!$idToken) {
518
+ throw new \Exception('No ID token available');
519
+ }
520
+
521
+ $solid = SolidClient::create(['identity' => $identity]);
522
+ $webId = $solid->oidc->getWebIdFromIdToken($idToken);
523
+ if (!$webId) {
524
+ throw new \Exception('No WebID found');
525
+ }
526
+
527
+ // IMPORTANT: fetch the *document* (strip #me)
528
+ $profileDoc = explode('#', $webId, 2)[0];
529
+
530
+ $profileResponse = $identity->request('get', $profileDoc, [], [
531
+ 'headers' => [
532
+ 'Accept' => 'text/turtle, application/ld+json;q=0.9, */*;q=0.1',
533
+ ],
534
+ ]);
535
+
536
+ if (!$profileResponse->successful()) {
537
+ throw new \Exception('Failed to fetch profile');
538
+ }
539
+
540
+ return [
541
+ 'webid' => $webId,
542
+ 'profile_data' => $profileResponse->body(),
543
+ 'parsed_profile' => $this->parseProfile($profileResponse->body()),
544
+ ];
545
+ }
546
+
547
+ /**
548
+ * Parse profile data.
549
+ */
550
+ private function parseProfile(string $profileData): array
551
+ {
552
+ $parsed = [
553
+ 'name' => null,
554
+ 'email' => null,
555
+ 'storage_locations' => [],
556
+ 'inbox' => null,
557
+ ];
558
+
559
+ // Parse storage locations with multiple patterns
560
+ $storagePatterns = [
561
+ '/pim:storage\s+<([^>]+)>/',
562
+ '/solid:storageQuota\s+<([^>]+)>/',
563
+ '/(\w+:)?storage\w*\s+<([^>]+)>/',
564
+ '/ldp:contains\s+<([^>]+)>/', // Sometimes storage is listed as contained resources
565
+ ];
566
+
567
+ foreach ($storagePatterns as $pattern) {
568
+ if (preg_match_all($pattern, $profileData, $matches)) {
569
+ $urls = isset($matches[2]) ? $matches[2] : $matches[1];
570
+ $parsed['storage_locations'] = array_merge($parsed['storage_locations'], $urls);
571
+ }
572
+ }
573
+
574
+ $parsed['storage_locations'] = array_unique($parsed['storage_locations']);
575
+
576
+ // Parse name with multiple patterns
577
+ $namePatterns = [
578
+ '/foaf:name\s+"([^"]+)"/',
579
+ '/foaf:name\s+\'([^\']+)\'/',
580
+ '/vcard:fn\s+"([^"]+)"/',
581
+ '/schema:name\s+"([^"]+)"/',
582
+ ];
583
+
584
+ foreach ($namePatterns as $pattern) {
585
+ if (preg_match($pattern, $profileData, $matches)) {
586
+ $parsed['name'] = $matches[1];
587
+ break;
588
+ }
589
+ }
590
+
591
+ // Parse email with multiple patterns
592
+ $emailPatterns = [
593
+ '/foaf:mbox\s+<mailto:([^>]+)>/',
594
+ '/vcard:hasEmail\s+<mailto:([^>]+)>/',
595
+ '/schema:email\s+"([^"]+)"/',
596
+ ];
597
+
598
+ foreach ($emailPatterns as $pattern) {
599
+ if (preg_match($pattern, $profileData, $matches)) {
600
+ $parsed['email'] = $matches[1];
601
+ break;
602
+ }
603
+ }
604
+
605
+ // Parse inbox
606
+ if (preg_match('/ldp:inbox\s+<([^>]+)>/', $profileData, $matches)) {
607
+ $parsed['inbox'] = $matches[1];
608
+ }
609
+
610
+ Log::info('[PARSED PROFILE]', [
611
+ 'storage_locations_count' => count($parsed['storage_locations']),
612
+ 'storage_locations' => $parsed['storage_locations'],
613
+ 'name' => $parsed['name'],
614
+ 'email' => $parsed['email'],
615
+ ]);
616
+
617
+ return $parsed;
618
+ }
619
+
620
+ /**
621
+ * Parse pod data from response.
622
+ */
623
+ private function parsePodData(string $url, string $content): array
624
+ {
625
+ $name = $this->extractPodName($url);
626
+ $id = Str::slug($name);
627
+
628
+ return [
629
+ 'id' => $id,
630
+ 'name' => $name,
631
+ 'url' => $url,
632
+ 'type' => 'storage',
633
+ 'containers' => $this->parseContainerContents($content),
634
+ 'size' => strlen($content),
635
+ 'last_modified' => now()->toISOString(),
636
+ ];
637
+ }
638
+
639
+ /**
640
+ * Extract pod name from URL.
641
+ */
642
+ private function extractPodName(string $url): string
643
+ {
644
+ $parts = explode('/', rtrim($url, '/'));
645
+
646
+ return end($parts) ?: 'Root Storage';
647
+ }
648
+
649
+ /**
650
+ * Get containers within storage locations.
651
+ */
652
+ private function getContainers(SolidIdentity $identity, array $storageLocations): array
653
+ {
654
+ $containers = [];
655
+
656
+ foreach ($storageLocations as $storageUrl) {
657
+ try {
658
+ $response = $identity->request('get', $storageUrl);
659
+ if ($response->successful()) {
660
+ $containerUrls = $this->parseContainerUrls($response->body());
661
+
662
+ foreach ($containerUrls as $containerUrl) {
663
+ $containers[] = [
664
+ 'id' => Str::slug($this->extractPodName($containerUrl)),
665
+ 'name' => $this->extractPodName($containerUrl),
666
+ 'url' => $containerUrl,
667
+ 'type' => 'container',
668
+ 'parent' => $storageUrl,
669
+ ];
670
+ }
671
+ }
672
+ } catch (\Throwable $e) {
673
+ Log::warning('[GET CONTAINERS WARNING]', [
674
+ 'storage_url' => $storageUrl,
675
+ 'error' => $e->getMessage(),
676
+ ]);
677
+ }
678
+ }
679
+
680
+ return $containers;
681
+ }
682
+
683
+ /**
684
+ * Parse container URLs from RDF content.
685
+ */
686
+ private function parseContainerUrls(string $content): array
687
+ {
688
+ $urls = [];
689
+
690
+ // Parse LDP containers
691
+ if (preg_match_all('/<([^>]+)>\s+a\s+ldp:Container/', $content, $matches)) {
692
+ $urls = array_merge($urls, $matches[1]);
693
+ }
694
+
695
+ // Parse LDP BasicContainer
696
+ if (preg_match_all('/<([^>]+)>\s+a\s+ldp:BasicContainer/', $content, $matches)) {
697
+ $urls = array_merge($urls, $matches[1]);
698
+ }
699
+
700
+ // Parse ldp:contains relationships
701
+ if (preg_match_all('/ldp:contains\s+<([^>]+\/)>/', $content, $matches)) {
702
+ $urls = array_merge($urls, $matches[1]);
703
+ }
704
+
705
+ return array_unique($urls);
706
+ }
707
+
708
+ /**
709
+ * Parse container contents.
710
+ */
711
+ private function parseContainerContents(string $content): array
712
+ {
713
+ $items = [];
714
+
715
+ // Parse contained resources
716
+ if (preg_match_all('/ldp:contains\s+<([^>]+)>/', $content, $matches)) {
717
+ foreach ($matches[1] as $resourceUrl) {
718
+ $items[] = [
719
+ 'url' => $resourceUrl,
720
+ 'name' => $this->extractPodName($resourceUrl),
721
+ 'type' => substr($resourceUrl, -1) === '/' ? 'container' : 'resource',
722
+ ];
723
+ }
724
+ }
725
+
726
+ return $items;
727
+ }
728
+
729
+ /**
730
+ * Generate pod metadata in Turtle format.
731
+ */
732
+ private function generatePodMetadata(string $name, ?string $description = null): string
733
+ {
734
+ $turtle = "@prefix dc: <http://purl.org/dc/terms/> .\n";
735
+ $turtle .= "@prefix foaf: <http://xmlns.com/foaf/0.1/> .\n";
736
+ $turtle .= "@prefix solid: <http://www.w3.org/ns/solid/terms#> .\n";
737
+ $turtle .= "@prefix ldp: <http://www.w3.org/ns/ldp#> .\n\n";
738
+
739
+ $turtle .= "<> a ldp:BasicContainer, ldp:Container ;\n";
740
+ $turtle .= " dc:title \"$name\" ;\n";
741
+
742
+ if ($description) {
743
+ $turtle .= " dc:description \"$description\" ;\n";
744
+ }
745
+
746
+ $turtle .= ' dc:created "' . now()->toISOString() . "\" .\n";
747
+
748
+ return $turtle;
749
+ }
750
+
751
+ /**
752
+ * Get pod URL from WebID.
753
+ *
754
+ * @param string $webId
755
+ * @return string
756
+ */
757
+ public function getPodUrlFromWebId(string $webId): string
758
+ {
759
+ // Extract pod URL from WebID
760
+ // WebID format: http://solid:3000/test/profile/card#me
761
+ // Pod URL: http://solid:3000/test/
762
+
763
+ $parsed = parse_url($webId);
764
+ $path = $parsed['path'] ?? '';
765
+
766
+ // Remove /profile/card from the path
767
+ $podPath = preg_replace('#/profile/card.*$#', '/', $path);
768
+
769
+ $podUrl = $parsed['scheme'] . '://' . $parsed['host'];
770
+ if (isset($parsed['port'])) {
771
+ $podUrl .= ':' . $parsed['port'];
772
+ }
773
+ $podUrl .= $podPath;
774
+
775
+ return $podUrl;
776
+ }
777
+
778
+ /**
779
+ * Create a folder (container) in the pod.
780
+ */
781
+ public function createFolder(SolidIdentity $identity, string $parentUrl, string $folderName): bool
782
+ {
783
+ try {
784
+ // Ensure parent URL ends with /
785
+ $parentUrl = rtrim($parentUrl, '/') . '/';
786
+
787
+ Log::info('[CREATE FOLDER]', [
788
+ 'parent_url' => $parentUrl,
789
+ 'folder_name' => $folderName,
790
+ ]);
791
+
792
+ // Create the folder using POST with Slug header (Solid Protocol standard)
793
+ $response = $identity->request('post', $parentUrl, '', [
794
+ 'headers' => [
795
+ 'Content-Type' => 'text/turtle',
796
+ 'Link' => '<http://www.w3.org/ns/ldp#BasicContainer>; rel="type"',
797
+ 'Slug' => $folderName,
798
+ ],
799
+ ]);
800
+
801
+ if ($response->successful()) {
802
+ $createdUrl = $response->header('Location') ?? $parentUrl . $folderName . '/';
803
+ Log::info('[FOLDER CREATED]', [
804
+ 'folder_url' => $createdUrl,
805
+ 'status' => $response->status(),
806
+ ]);
807
+ return true;
808
+ }
809
+
810
+ Log::error('[FOLDER CREATE FAILED]', [
811
+ 'parent_url' => $parentUrl,
812
+ 'folder_name' => $folderName,
813
+ 'status' => $response->status(),
814
+ 'body' => $response->body(),
815
+ ]);
816
+
817
+ return false;
818
+ } catch (\Throwable $e) {
819
+ Log::error('[FOLDER CREATE ERROR]', [
820
+ 'folder_url' => $folderUrl,
821
+ 'error' => $e->getMessage(),
822
+ ]);
823
+ throw $e;
824
+ }
825
+ }
826
+
827
+ /**
828
+ * Delete a resource (file or folder) from the pod.
829
+ */
830
+ public function deleteResource(SolidIdentity $identity, string $resourceUrl): bool
831
+ {
832
+ try {
833
+ Log::info('[DELETE RESOURCE]', [
834
+ 'resource_url' => $resourceUrl,
835
+ ]);
836
+
837
+ // Delete the resource using DELETE request
838
+ $response = $identity->request('delete', $resourceUrl);
839
+
840
+ if ($response->successful()) {
841
+ Log::info('[RESOURCE DELETED]', [
842
+ 'resource_url' => $resourceUrl,
843
+ 'status' => $response->status(),
844
+ ]);
845
+ return true;
846
+ }
847
+
848
+ Log::error('[RESOURCE DELETE FAILED]', [
849
+ 'resource_url' => $resourceUrl,
850
+ 'status' => $response->status(),
851
+ 'body' => $response->body(),
852
+ ]);
853
+
854
+ return false;
855
+ } catch (\Throwable $e) {
856
+ Log::error('[RESOURCE DELETE ERROR]', [
857
+ 'resource_url' => $resourceUrl,
858
+ 'error' => $e->getMessage(),
859
+ ]);
860
+ throw $e;
861
+ }
862
+ }
863
+ }