From 88ef666769083fb32d811e98ee82a99818edb6a6 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 26 May 2026 23:33:41 +1000 Subject: [PATCH 1/3] Decrypt the content when a resource is shared publicly --- lib/solidpod.dart | 8 + lib/src/solid/grant_permission.dart | 140 +++++++++------ lib/src/solid/public_sharing_hooks.dart | 65 +++++++ lib/src/solid/revoke_permission.dart | 39 ++-- lib/src/solid/utils/misc.dart | 227 ++++++++++++++++++++++++ 5 files changed, 415 insertions(+), 64 deletions(-) create mode 100644 lib/src/solid/public_sharing_hooks.dart diff --git a/lib/solidpod.dart b/lib/solidpod.dart index d7c4b025..32df2043 100644 --- a/lib/solidpod.dart +++ b/lib/solidpod.dart @@ -112,6 +112,7 @@ export 'src/solid/utils/rdf.dart' show turtleToTripleMap, tripleMapToTurtle; export 'src/solid/utils/misc.dart' show + applyPublicShareDecryptedHookInPlace, createContainer, createDir, isPathInCurrentApp, @@ -176,6 +177,13 @@ export 'src/solid/write_pod.dart'; export 'src/solid/grant_permission.dart'; +/// Application-level hooks invoked by solidpod when a resource is +/// decrypted in place for public/auth-user sharing, or re-encrypted +/// in place after such sharing is revoked. + +export 'src/solid/public_sharing_hooks.dart' + show PublicSharingHooks, PublicSharingContentTransformer; + /// The function to read permissions given to a resource export 'src/solid/read_permission.dart'; diff --git a/lib/src/solid/grant_permission.dart b/lib/src/solid/grant_permission.dart index 32708d2a..187a75d1 100644 --- a/lib/src/solid/grant_permission.dart +++ b/lib/src/solid/grant_permission.dart @@ -140,71 +140,103 @@ Future grantPermission({ isFile, ); - // Check if the file is encrypted - final fileIsEncrypted = await checkFileEnc( + // Check whether an individual encryption key has been recorded + // for the resource. This is the legacy "is encrypted" signal: + // it tells us the user originally chose to protect the file but + // it does not tell us whether the bytes on the server are still + // encrypted at this moment (see `isFileContentEncrypted`). + final fileHasIndKey = await checkFileEnc( resourceUrl, isExternalRes: isExternalRes, ); + debugPrint( + '[grantPermission] resourceUrl="$resourceUrl" ' + 'recipientType=$recipientType ' + 'hasSpecificRecipients=$hasSpecificRecipients ' + 'fileHasIndKey=$fileHasIndKey ' + 'isExternalRes=$isExternalRes', + ); + + if (hasSpecificRecipients && fileHasIndKey) { + // Permission granted to specific individuals or groups: share + // the individual encryption key with each recipient via their + // POD so they can decrypt the file content. - // If the file is encrypted then share the individual encryption key - // with the receiver - if (fileIsEncrypted) { - // Get the individual encryption key for the file final indKey = isExternalRes ? await KeyManager.getSharedIndividualKey(resourceUrl) : await KeyManager.getIndividualKey(resourceUrl); assert(indKey != null); - // If permission granted to specific recipients - if (hasSpecificRecipients) { - // For each recipient share the individual encryption key - - for (final recipientWebId in recipientWebIdList) { - // Setup recipient's public key - final recipientPubKey = RecipientPubKey( - recipientWebId: recipientWebId as String, - ); - - // Encrypt individual key - final sharedIndKey = await recipientPubKey.encryptData( - indKey!.base64, - ); - - // Encrypt resource URL - final sharedResPath = await recipientPubKey.encryptData( - resourceUrl, - ); - - // Encrypt the list of permissions - permissionList.sort(); - final sharedAccessList = await recipientPubKey.encryptData( - permissionList.join(','), - ); - - // Generate unique ID for the resource being shared - final resUniqueId = getUniqueIdResUrl( - resourceUrl, - recipientWebId, - ); - - // Copy shared content to recipient's POD - await copySharedKey( - recipientWebId, - resUniqueId, - sharedIndKey, - sharedResPath, - sharedAccessList, - ); - } - } else { - // if the recipient type is either public or authenticated agent - // Copy the key to a publicly available or authenticated user accessible file - await copySharedKeyUserClass( - indKey!, + for (final recipientWebId in recipientWebIdList) { + // Setup recipient's public key + final recipientPubKey = RecipientPubKey( + recipientWebId: recipientWebId as String, + ); + + // Encrypt individual key + final sharedIndKey = await recipientPubKey.encryptData( + indKey!.base64, + ); + + // Encrypt resource URL + final sharedResPath = await recipientPubKey.encryptData( + resourceUrl, + ); + + // Encrypt the list of permissions + permissionList.sort(); + final sharedAccessList = await recipientPubKey.encryptData( + permissionList.join(','), + ); + + // Generate unique ID for the resource being shared + final resUniqueId = getUniqueIdResUrl( + resourceUrl, + recipientWebId, + ); + + // Copy shared content to recipient's POD + await copySharedKey( + recipientWebId, + resUniqueId, + sharedIndKey, + sharedResPath, + sharedAccessList, + ); + } + } else if (!hasSpecificRecipients) { + // Permission granted to the Public or Authenticated User class: + // these recipients cannot be issued an individual key (they + // have no POD/private key under our control), so the only way + // for them to actually read the resource by navigating to its + // URL is for the file itself to be plaintext on the server. + // + // Always check the actual bytes on the server here, even when + // `fileHasIndKey` is false: this is more robust against the + // legacy case where the ind-key record might have been + // dropped while the file content is still encrypted. + // + // The individual key is intentionally kept in `ind-keys.ttl` + // so that the file can be re-encrypted later if public or + // authenticated-user access is revoked. + if (await isFileContentEncrypted(resourceUrl)) { + debugPrint('[grantPermission] decrypting "$resourceUrl" for ' + 'public/authUser sharing'); + await decryptFileInPlace( resourceUrl, - permissionList, - recipientType, + isExternalRes: isExternalRes, ); + } else { + debugPrint('[grantPermission] outer layer already plaintext: ' + '"$resourceUrl"'); + + // Even when the outer encrypted-TTL wrapper is gone, the + // host app may still have an inner encryption layer that + // needs unwrapping (e.g. NotePod's per-note noteContent). + // Invoke the public-share decrypted hook directly so that + // a file left in this mixed state by an older version of + // solidpod is still made fully readable. + await applyPublicShareDecryptedHookInPlace(resourceUrl); } } diff --git a/lib/src/solid/public_sharing_hooks.dart b/lib/src/solid/public_sharing_hooks.dart new file mode 100644 index 00000000..025b3380 --- /dev/null +++ b/lib/src/solid/public_sharing_hooks.dart @@ -0,0 +1,65 @@ +/// Application-level hooks for the public/authenticated-user sharing lifecycle. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Tony Chen + +library; + +/// Signature of a transformer that rewrites the (plaintext) content of a +/// resource as part of the public/authenticated-user sharing lifecycle. + +typedef PublicSharingContentTransformer = Future Function( + String resourceUrl, + String content, +); + +/// Hook points that let host applications layer additional content +/// transformations on top of solidpod's own encrypted-TTL wrapper when +/// files are shared with the Public or Authenticated User classes. + +class PublicSharingHooks { + PublicSharingHooks._(); + + /// Invoked by [decryptFileInPlace] after solidpod has unwrapped the + /// outer encrypted-TTL layer, immediately before writing the result + /// back to the server. The returned value is what gets persisted. + + static PublicSharingContentTransformer? onPublicShareDecrypted; + + /// Invoked by [encryptFileInPlace] before solidpod re-wraps a file + /// in its outer encrypted-TTL layer (e.g. after public/auth-user + /// access is revoked). The returned value is what then gets fed + /// into [getEncTTLStr] to produce the encrypted payload at rest. + + static PublicSharingContentTransformer? onPublicShareRevoked; + + /// Reset all registered hooks. Primarily intended for tests. + + static void clear() { + onPublicShareDecrypted = null; + onPublicShareRevoked = null; + } +} diff --git a/lib/src/solid/revoke_permission.dart b/lib/src/solid/revoke_permission.dart index 16e3f6cd..0b7e4664 100644 --- a/lib/src/solid/revoke_permission.dart +++ b/lib/src/solid/revoke_permission.dart @@ -30,6 +30,8 @@ library; import 'dart:core'; +import 'package:flutter/foundation.dart' show debugPrint; + import 'package:solidpod/src/solid/api/common_permission.dart'; import 'package:solidpod/src/solid/api/rest_api.dart'; import 'package:solidpod/src/solid/api/revoke_permission_api.dart'; @@ -106,18 +108,21 @@ Future revokePermission({ recipientWebIdList.add(recipientIndOrGroupWebId); } - // Check if the file is encrypted - final fileIsEncrypted = await checkFileEnc( + // Whether the resource has an associated individual encryption key. + // This is the legacy "is encrypted" signal: it reflects the user's + // original intent but does not tell us whether the bytes on the + // server are encrypted at this moment (a file shared with the + // Public or Authenticated User class is decrypted in place by + // `grantPermission`). + final fileHasIndKey = await checkFileEnc( resourceUrl, isExternalRes: isExternalRes, ); - // If the file is encrypted then remove the individual key from relavant - // users/ user classes - if (fileIsEncrypted) { - // If access revoked for specific recipients, remove key - // from recipient's POD + if (fileHasIndKey) { if (specificRecipientTypeList.contains(recipientType)) { + // Remove the per-recipient copy of the individual key from each + // recipient's POD when access is revoked from specific recipients. for (final recipientWebId in recipientWebIdList) { // Check if POD file structure is still there if (await checkPodInitialised(recipientWebId as String)) { @@ -129,9 +134,11 @@ Future revokePermission({ } } } else { - // if the recipient type is either public or authenticated agent - // Remove the key from the publicly available or authenticated user - // accessible file + // Best-effort cleanup of the legacy public/authenticated-user + // shared key file. Newer versions of solidpod no longer write to + // these files when granting public/auth-user access (the resource + // is decrypted in place instead), but PODs initialised by older + // versions may still contain stale entries. await removeSharedKeyUserClass(resourceUrl, recipientType); } } @@ -145,6 +152,18 @@ Future revokePermission({ isFile: isFile, ); + // When revoking access from the Public or Authenticated User class, + // re-encrypt the resource if it had previously been decrypted in + // place for sharing. The presence of an individual key indicates + // the user originally intended the file to be encrypted at rest; + // `encryptFileInPlace` is a no-op when the file is already in the + // encrypted TTL format or when no individual key is available. + if (fileHasIndKey && !specificRecipientTypeList.contains(recipientType)) { + debugPrint('[revokePermission] re-encrypting "$resourceUrl" after ' + 'revoking $recipientType access'); + await encryptFileInPlace(resourceUrl, isExternalRes: isExternalRes); + } + // Add log entry to owner, granter, and receiver permission log files for (final recipientWebId in recipientWebIdList) { diff --git a/lib/src/solid/utils/misc.dart b/lib/src/solid/utils/misc.dart index 06f1fbf4..06d387c0 100644 --- a/lib/src/solid/utils/misc.dart +++ b/lib/src/solid/utils/misc.dart @@ -30,6 +30,8 @@ library; +import 'dart:convert' show utf8; + import 'package:flutter/foundation.dart' show debugPrint; import 'package:encrypter_plus/encrypter_plus.dart'; @@ -45,6 +47,7 @@ import 'package:solidpod/src/solid/api/rest_api.dart'; import 'package:solidpod/src/solid/constants/common.dart'; import 'package:solidpod/src/solid/constants/path_type.dart'; import 'package:solidpod/src/solid/constants/schema.dart'; +import 'package:solidpod/src/solid/public_sharing_hooks.dart'; import 'package:solidpod/src/solid/utils/app_info.dart'; import 'package:solidpod/src/solid/utils/authdata_manager.dart'; import 'package:solidpod/src/solid/utils/data_encryption.dart'; @@ -150,6 +153,230 @@ Future getEncTTLStr({ return tripleMapToTurtle(triples, bindNamespaces: bindNS); } +/// Inspect the on-server content of [fileUrl] and determine whether it is +/// currently in the encrypted TTL format produced by [getEncTTLStr]. + +Future isFileContentEncrypted(String fileUrl) async { + if (!fileUrl.toLowerCase().endsWith('.ttl')) { + debugPrint('[isFileContentEncrypted] non-ttl url, returning false: ' + '"$fileUrl"'); + return false; + } + try { + if (await checkResourceStatus(fileUrl) != ResourceStatus.exist) { + debugPrint('[isFileContentEncrypted] resource does not exist: ' + '"$fileUrl"'); + return false; + } + final raw = utf8.decode(await getResource(fileUrl)); + final encMap = _extractEncFields(raw, fileUrl); + if (encMap == null) { + debugPrint( + '[isFileContentEncrypted] no iv/encData found for "$fileUrl"', + ); + return false; + } + debugPrint('[isFileContentEncrypted] file IS encrypted: "$fileUrl"'); + return true; + } on Object catch (e) { + debugPrint('[isFileContentEncrypted] unable to inspect "$fileUrl": $e'); + return false; + } +} + +/// Look at the triple map produced by [turtleToTripleMap] and return the +/// `(iv, encData)` pair for [fileUrl] if the document is in the encrypted +/// TTL format, or `null` if it is not. + +({String iv, String encData})? _extractEncFields( + String rawTtl, + String fileUrl, +) { + final tripleMap = turtleToTripleMap(rawTtl); + final ivKey = solidTermsNS.ns.withAttr(ivPred).value; + final encKey = solidTermsNS.ns.withAttr(encDataPred).value; + + ({String iv, String encData})? pickFrom(Map m) { + final iv = m[ivKey]; + final enc = m[encKey]; + if (iv is String && enc is String) { + return (iv: iv, encData: enc); + } + return null; + } + + final direct = tripleMap[fileUrl]; + if (direct != null) { + final r = pickFrom(direct); + if (r != null) return r; + } + + for (final entry in tripleMap.entries) { + if (entry.key == fileUrl) continue; + final r = pickFrom(entry.value); + if (r != null) { + debugPrint( + '[isFileContentEncrypted] iv/encData found under fallback subject ' + '"${entry.key}" instead of "$fileUrl"', + ); + return r; + } + } + + return null; +} + +/// Decrypt the content of [fileUrl] in place on the server, replacing the +/// encrypted TTL payload with the plaintext that was originally written. + +Future decryptFileInPlace( + String fileUrl, { + bool isExternalRes = false, +}) async { + debugPrint('[decryptFileInPlace] start url="$fileUrl" ' + 'isExternalRes=$isExternalRes'); + final raw = utf8.decode(await getResource(fileUrl)); + final encMap = _extractEncFields(raw, fileUrl); + + if (encMap == null) { + debugPrint( + '[decryptFileInPlace] file is not in encrypted format, nothing to do: ' + '"$fileUrl"', + ); + return; + } + + final indKey = isExternalRes + ? await KeyManager.getSharedIndividualKey(fileUrl) + : await KeyManager.getIndividualKey(fileUrl); + + if (indKey == null) { + throw Exception( + 'No individual encryption key available for "$fileUrl"; ' + 'cannot decrypt the file for public/authenticated-user sharing.', + ); + } + + var plaintext = decryptData( + encMap.encData, + indKey, + IV.fromBase64(encMap.iv), + ); + + // Allow the host app to strip an additional application-level + // encryption layer before the file is exposed publicly. + + final postHook = PublicSharingHooks.onPublicShareDecrypted; + if (postHook != null) { + debugPrint( + '[decryptFileInPlace] applying app onPublicShareDecrypted hook', + ); + plaintext = await postHook(fileUrl, plaintext); + } + + await createResource( + fileUrl, + content: plaintext, + contentType: ResourceContentType.turtleText, + ); + debugPrint('[decryptFileInPlace] wrote plaintext (${plaintext.length} ' + 'bytes) to "$fileUrl"'); +} + +/// Apply the [PublicSharingHooks.onPublicShareDecrypted] transformer to +/// the current (already-plaintext) bytes of [fileUrl] and write back any +/// resulting change. + +Future applyPublicShareDecryptedHookInPlace(String fileUrl) async { + final hook = PublicSharingHooks.onPublicShareDecrypted; + if (hook == null) { + debugPrint( + '[applyPublicShareDecryptedHookInPlace] no hook registered, ' + 'nothing to do: "$fileUrl"', + ); + return; + } + + try { + final current = utf8.decode(await getResource(fileUrl)); + final transformed = await hook(fileUrl, current); + if (transformed == current) { + debugPrint( + '[applyPublicShareDecryptedHookInPlace] hook left content ' + 'unchanged: "$fileUrl"', + ); + return; + } + await createResource( + fileUrl, + content: transformed, + contentType: ResourceContentType.turtleText, + ); + debugPrint( + '[applyPublicShareDecryptedHookInPlace] hook rewrote content ' + '(${transformed.length} bytes) for "$fileUrl"', + ); + } on Object catch (e) { + debugPrint( + '[applyPublicShareDecryptedHookInPlace] hook failed for "$fileUrl": $e', + ); + } +} + +/// Encrypt the (currently plaintext) content of [fileUrl] in place using +/// the individual encryption key already recorded for the resource. + +Future encryptFileInPlace( + String fileUrl, { + bool isExternalRes = false, +}) async { + debugPrint('[encryptFileInPlace] start url="$fileUrl" ' + 'isExternalRes=$isExternalRes'); + final indKey = isExternalRes + ? await KeyManager.getSharedIndividualKey(fileUrl) + : await KeyManager.getIndividualKey(fileUrl); + if (indKey == null) { + debugPrint('[encryptFileInPlace] no individual key for "$fileUrl", ' + 'leaving file as plaintext'); + return; + } + + if (await isFileContentEncrypted(fileUrl)) { + debugPrint('[encryptFileInPlace] file already encrypted, nothing to do: ' + '"$fileUrl"'); + return; + } + + var plaintext = utf8.decode(await getResource(fileUrl)); + + // Give the host app a chance to restore an inner application-level + // encryption layer that was peeled off by [decryptFileInPlace] when + // public/auth-user sharing was granted, so that at-rest state is + // symmetric with what the app originally wrote via [writePod]. + + final preHook = PublicSharingHooks.onPublicShareRevoked; + if (preHook != null) { + debugPrint( + '[encryptFileInPlace] applying app onPublicShareRevoked hook', + ); + plaintext = await preHook(fileUrl, plaintext); + } + + final encContent = await getEncTTLStr( + fileUrl: fileUrl, + fileContent: plaintext, + key: indKey, + iv: IV.fromLength(16), + ); + + await createResource( + fileUrl, + content: encContent, + contentType: ResourceContentType.turtleText, + ); + debugPrint('[encryptFileInPlace] re-encrypted "$fileUrl"'); +} + /// Returns the path of file with verification key and private key Future getEncKeyPath() async => [appDirName, encDir, encKeyFile].join('/'); From 89a2dcc4bdd9a5519a05528370aab9aab7753d5b Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 26 May 2026 23:53:25 +1000 Subject: [PATCH 2/3] Lint (locmax) --- lib/src/solid/utils/misc.dart | 778 +---------------------- lib/src/solid/utils/misc_auth.dart | 243 +++++++ lib/src/solid/utils/misc_container.dart | 107 ++++ lib/src/solid/utils/misc_encryption.dart | 293 +++++++++ lib/src/solid/utils/misc_paths.dart | 226 +++++++ 5 files changed, 873 insertions(+), 774 deletions(-) create mode 100644 lib/src/solid/utils/misc_auth.dart create mode 100644 lib/src/solid/utils/misc_container.dart create mode 100644 lib/src/solid/utils/misc_encryption.dart create mode 100644 lib/src/solid/utils/misc_paths.dart diff --git a/lib/src/solid/utils/misc.dart b/lib/src/solid/utils/misc.dart index 06d387c0..df498c89 100644 --- a/lib/src/solid/utils/misc.dart +++ b/lib/src/solid/utils/misc.dart @@ -30,42 +30,15 @@ library; -import 'dart:convert' show utf8; - -import 'package:flutter/foundation.dart' show debugPrint; - -import 'package:encrypter_plus/encrypter_plus.dart'; -import 'package:fast_rsa/fast_rsa.dart' show KeyPair; -import 'package:http/http.dart' as http; import 'package:intl/intl.dart'; -import 'package:jwt_decoder/jwt_decoder.dart'; -import 'package:path/path.dart' as path; -import 'package:rdflib/rdflib.dart'; -import 'package:solid_auth/solid_auth.dart' show genDpopToken, logout; -import 'package:solidpod/src/solid/api/rest_api.dart'; import 'package:solidpod/src/solid/constants/common.dart'; -import 'package:solidpod/src/solid/constants/path_type.dart'; -import 'package:solidpod/src/solid/constants/schema.dart'; -import 'package:solidpod/src/solid/public_sharing_hooks.dart'; import 'package:solidpod/src/solid/utils/app_info.dart'; -import 'package:solidpod/src/solid/utils/authdata_manager.dart'; -import 'package:solidpod/src/solid/utils/data_encryption.dart'; -import 'package:solidpod/src/solid/utils/get_url_helper.dart'; -import 'package:solidpod/src/solid/utils/key_manager.dart'; -import 'package:solidpod/src/solid/utils/rdf.dart'; -/// Global callback for clearing application-specific caches during logout. -/// Apps should register their cache clearing logic here. -/// This ensures caches are cleared BEFORE any blocking network operations. -Future Function()? _onLogoutClearCaches; - -/// Register a callback to clear application-specific caches during logout. -/// This callback will be invoked BEFORE the OAuth2 logout endpoint call, -/// preventing race conditions where cached data might be visible during logout. -void registerLogoutCacheCallback(Future Function() callback) { - _onLogoutClearCaches = callback; -} +export 'package:solidpod/src/solid/utils/misc_auth.dart'; +export 'package:solidpod/src/solid/utils/misc_container.dart'; +export 'package:solidpod/src/solid/utils/misc_encryption.dart'; +export 'package:solidpod/src/solid/utils/misc_paths.dart'; // solid-encrypt uses unencrypted local storage and refers to http: //yarrabah.net/ for predicates definition, // do not use it before it is updated (same as what the gurriny project does) @@ -89,487 +62,12 @@ Future writeToSecureStorage(String key, String value) async { await secureStorage.write(key: key, value: value); } -/// Load and parse a private TTL file from POD -// Future> loadPrvTTL(String fileUrl) async { -// // final fileUrl = await getFileUrl(filePath); -// try { -// if (await checkResourceStatus(fileUrl) == ResourceStatus.exist) { -// final rawContent = await fetchPrvFile(fileUrl); -// return parseTTL(rawContent); -// } else { -// return {}; -// } -// } on Exception catch (e) { -// throw Exception(e); -// } -// } - -/// Read the encryption key file content for display purposes. -/// -/// This function directly reads the encryption key file without using readPod, -/// making it suitable for accessing files outside the appname/data directory. -/// -/// Returns the raw TTL content of the encryption key file. -// Future readEncryptionKeyContent() async { -// final encKeyPath = await getEncKeyPath(); -// final encKeyUrl = await getFileUrl(encKeyPath); - -// try { -// if (await checkResourceStatus(encKeyUrl) == ResourceStatus.exist) { -// return utf8.decode( -// await getResource(encKeyUrl), -// ); - -// return await fetchPrvFile(encKeyUrl); -// } else { -// throw Exception('Encryption key file does not exist at: $encKeyPath'); -// } -// } on Exception catch (e) { -// throw Exception('Failed to read encryption key file: $e'); -// } -// } - -/// Encrypt a given data string and format to TTL -Future getEncTTLStr({ - required String fileUrl, - required String fileContent, - required Key key, - required IV iv, - String? inheritKeyFrom, -}) async { - final filePath = await extractResourcePathFromUrl(fileUrl); - final triples = { - URIRef(fileUrl): { - solidTermsNS.ns.withAttr(pathPred): filePath, - solidTermsNS.ns.withAttr(ivPred): iv.base64, - if (inheritKeyFrom != null) - solidTermsNS.ns.withAttr(inheritKeyPred): inheritKeyFrom, - solidTermsNS.ns.withAttr(encDataPred): encryptData(fileContent, key, iv), - }, - }; - - final bindNS = {solidTermsNS.prefix: solidTermsNS.ns}; - - return tripleMapToTurtle(triples, bindNamespaces: bindNS); -} - -/// Inspect the on-server content of [fileUrl] and determine whether it is -/// currently in the encrypted TTL format produced by [getEncTTLStr]. - -Future isFileContentEncrypted(String fileUrl) async { - if (!fileUrl.toLowerCase().endsWith('.ttl')) { - debugPrint('[isFileContentEncrypted] non-ttl url, returning false: ' - '"$fileUrl"'); - return false; - } - try { - if (await checkResourceStatus(fileUrl) != ResourceStatus.exist) { - debugPrint('[isFileContentEncrypted] resource does not exist: ' - '"$fileUrl"'); - return false; - } - final raw = utf8.decode(await getResource(fileUrl)); - final encMap = _extractEncFields(raw, fileUrl); - if (encMap == null) { - debugPrint( - '[isFileContentEncrypted] no iv/encData found for "$fileUrl"', - ); - return false; - } - debugPrint('[isFileContentEncrypted] file IS encrypted: "$fileUrl"'); - return true; - } on Object catch (e) { - debugPrint('[isFileContentEncrypted] unable to inspect "$fileUrl": $e'); - return false; - } -} - -/// Look at the triple map produced by [turtleToTripleMap] and return the -/// `(iv, encData)` pair for [fileUrl] if the document is in the encrypted -/// TTL format, or `null` if it is not. - -({String iv, String encData})? _extractEncFields( - String rawTtl, - String fileUrl, -) { - final tripleMap = turtleToTripleMap(rawTtl); - final ivKey = solidTermsNS.ns.withAttr(ivPred).value; - final encKey = solidTermsNS.ns.withAttr(encDataPred).value; - - ({String iv, String encData})? pickFrom(Map m) { - final iv = m[ivKey]; - final enc = m[encKey]; - if (iv is String && enc is String) { - return (iv: iv, encData: enc); - } - return null; - } - - final direct = tripleMap[fileUrl]; - if (direct != null) { - final r = pickFrom(direct); - if (r != null) return r; - } - - for (final entry in tripleMap.entries) { - if (entry.key == fileUrl) continue; - final r = pickFrom(entry.value); - if (r != null) { - debugPrint( - '[isFileContentEncrypted] iv/encData found under fallback subject ' - '"${entry.key}" instead of "$fileUrl"', - ); - return r; - } - } - - return null; -} - -/// Decrypt the content of [fileUrl] in place on the server, replacing the -/// encrypted TTL payload with the plaintext that was originally written. - -Future decryptFileInPlace( - String fileUrl, { - bool isExternalRes = false, -}) async { - debugPrint('[decryptFileInPlace] start url="$fileUrl" ' - 'isExternalRes=$isExternalRes'); - final raw = utf8.decode(await getResource(fileUrl)); - final encMap = _extractEncFields(raw, fileUrl); - - if (encMap == null) { - debugPrint( - '[decryptFileInPlace] file is not in encrypted format, nothing to do: ' - '"$fileUrl"', - ); - return; - } - - final indKey = isExternalRes - ? await KeyManager.getSharedIndividualKey(fileUrl) - : await KeyManager.getIndividualKey(fileUrl); - - if (indKey == null) { - throw Exception( - 'No individual encryption key available for "$fileUrl"; ' - 'cannot decrypt the file for public/authenticated-user sharing.', - ); - } - - var plaintext = decryptData( - encMap.encData, - indKey, - IV.fromBase64(encMap.iv), - ); - - // Allow the host app to strip an additional application-level - // encryption layer before the file is exposed publicly. - - final postHook = PublicSharingHooks.onPublicShareDecrypted; - if (postHook != null) { - debugPrint( - '[decryptFileInPlace] applying app onPublicShareDecrypted hook', - ); - plaintext = await postHook(fileUrl, plaintext); - } - - await createResource( - fileUrl, - content: plaintext, - contentType: ResourceContentType.turtleText, - ); - debugPrint('[decryptFileInPlace] wrote plaintext (${plaintext.length} ' - 'bytes) to "$fileUrl"'); -} - -/// Apply the [PublicSharingHooks.onPublicShareDecrypted] transformer to -/// the current (already-plaintext) bytes of [fileUrl] and write back any -/// resulting change. - -Future applyPublicShareDecryptedHookInPlace(String fileUrl) async { - final hook = PublicSharingHooks.onPublicShareDecrypted; - if (hook == null) { - debugPrint( - '[applyPublicShareDecryptedHookInPlace] no hook registered, ' - 'nothing to do: "$fileUrl"', - ); - return; - } - - try { - final current = utf8.decode(await getResource(fileUrl)); - final transformed = await hook(fileUrl, current); - if (transformed == current) { - debugPrint( - '[applyPublicShareDecryptedHookInPlace] hook left content ' - 'unchanged: "$fileUrl"', - ); - return; - } - await createResource( - fileUrl, - content: transformed, - contentType: ResourceContentType.turtleText, - ); - debugPrint( - '[applyPublicShareDecryptedHookInPlace] hook rewrote content ' - '(${transformed.length} bytes) for "$fileUrl"', - ); - } on Object catch (e) { - debugPrint( - '[applyPublicShareDecryptedHookInPlace] hook failed for "$fileUrl": $e', - ); - } -} - -/// Encrypt the (currently plaintext) content of [fileUrl] in place using -/// the individual encryption key already recorded for the resource. - -Future encryptFileInPlace( - String fileUrl, { - bool isExternalRes = false, -}) async { - debugPrint('[encryptFileInPlace] start url="$fileUrl" ' - 'isExternalRes=$isExternalRes'); - final indKey = isExternalRes - ? await KeyManager.getSharedIndividualKey(fileUrl) - : await KeyManager.getIndividualKey(fileUrl); - if (indKey == null) { - debugPrint('[encryptFileInPlace] no individual key for "$fileUrl", ' - 'leaving file as plaintext'); - return; - } - - if (await isFileContentEncrypted(fileUrl)) { - debugPrint('[encryptFileInPlace] file already encrypted, nothing to do: ' - '"$fileUrl"'); - return; - } - - var plaintext = utf8.decode(await getResource(fileUrl)); - - // Give the host app a chance to restore an inner application-level - // encryption layer that was peeled off by [decryptFileInPlace] when - // public/auth-user sharing was granted, so that at-rest state is - // symmetric with what the app originally wrote via [writePod]. - - final preHook = PublicSharingHooks.onPublicShareRevoked; - if (preHook != null) { - debugPrint( - '[encryptFileInPlace] applying app onPublicShareRevoked hook', - ); - plaintext = await preHook(fileUrl, plaintext); - } - - final encContent = await getEncTTLStr( - fileUrl: fileUrl, - fileContent: plaintext, - key: indKey, - iv: IV.fromLength(16), - ); - - await createResource( - fileUrl, - content: encContent, - contentType: ResourceContentType.turtleText, - ); - debugPrint('[encryptFileInPlace] re-encrypted "$fileUrl"'); -} - -/// Returns the path of file with verification key and private key -Future getEncKeyPath() async => - [appDirName, encDir, encKeyFile].join('/'); - -/// Returns the path of file with individual keys -Future getIndKeyPath() async => - [appDirName, encDir, indKeyFile].join('/'); - -/// Returns the path of file with public keys -Future getPubKeyPath() async => - [appDirName, sharingDir, pubKeyFile].join('/'); - -/// Returns the path of public file with individual keys -Future getPubIndKeyPath() async => - [appDirName, sharingDir, pubIndKeyFile].join('/'); - -/// Returns the path of file with individual keys accessed only -/// by authenticated users -Future getAuthUserIndKeyPath() async => - [appDirName, sharingDir, authUserIndKeyFile].join('/'); - -/// Returns the path of the data directory -Future getDataDirPath() async => [appDirName, dataDir].join('/'); - -/// Checks whether a POD-relative [resourcePath] falls within the current -/// application's directory tree. -/// -/// Returns `true` if the resource belongs to this app, meaning the app -/// holds the encryption key required to decrypt it. Returns `false` if -/// the resource belongs to another application's folder, in which case -/// decryption may not be possible. -/// -/// [resourcePath] should be a normalised POD-relative path (e.g. -/// `myapp/data/file.ttl`). Absolute URLs or empty strings return `false`. - -Future isPathInCurrentApp(String resourcePath) async { - try { - if (resourcePath.trim().isEmpty) return false; - - if (resourcePath.startsWith('http://') || - resourcePath.startsWith('https://')) { - debugPrint( - 'isPathInCurrentApp: expected a POD-relative path but received ' - 'an absolute URL: $resourcePath', - ); - return false; - } - - // Derive the current app name from getDataDirPath() which returns - // "APP_NAME/data". The first segment is the app name. - - final appDataPath = await getDataDirPath(); - if (appDataPath.isEmpty) return false; - - final currentAppName = appDataPath.split('/').first; - if (currentAppName.isEmpty) return false; - - // Build full URLs for both the resource and the app root, then - // compare prefixes. getDirUrl appends a trailing slash which - // prevents false positives (e.g. "myapp2" matching "myapp"). - - final resourceUrl = await getFileUrl(resourcePath); - final appRootUrl = await getDirUrl(currentAppName); - - return resourceUrl.startsWith(appRootUrl); - } catch (e) { - debugPrint('Error in isPathInCurrentApp: $e'); - return false; - } -} - -/// Returns the path of the shared directory -Future getSharedDirPath() async => [appDirName, sharedDir].join('/'); - -/// Returns the path of the file with shared individual keys -Future getSharedKeyFilePath() async => - [appDirName, sharedDir, sharedKeyFile].join('/'); - -/// Returns the path of the encryption directory -Future getEncDirPath() async => [appDirName, encDir].join('/'); - -/// Returns the path of the encryption directory -Future getPermLogFilePath() async => - [appDirName, logsDir, permLogFile].join('/'); - /// Extract the app name and the version from the package info /// Return a record (with named fields https://dart.dev/language/records) Future<({String name, String version})> getAppNameVersion() async => (name: await AppInfo.name, version: await AppInfo.version); -/// Return the web ID -Future getWebId() async => AuthDataManager.getWebId(); - -/// Check whether a user is logged in or not -/// -/// Check if the local storage has authentication -/// details of the user and also check whether the -/// access token is expired or not - -Future isUserLoggedIn() async { - final webId = await AuthDataManager.getWebId(); - - if (webId != null && webId.isNotEmpty) { - final accessToken = await AuthDataManager.getAccessToken(); - if (accessToken != null && !JwtDecoder.isExpired(accessToken)) { - return true; - } - } - - return false; -} - -/// Create a directory with the given URL. - -Future createDir(String dirUrl) async { - assert(dirUrl.endsWith('/')); - await createResource( - dirUrl, - isFile: false, - replaceIfExist: false, - contentType: ResourceContentType.directory, - ); -} - -/// Characters that are forbidden in container (folder) names. -/// -/// These characters are either URL-unsafe (causing percent-encoding issues -/// such as spaces becoming `%20`) or filesystem-unsafe on common platforms. - -final RegExp _invalidContainerNameChars = RegExp( - r'''[ /#?%&+@=<>"|*:!\\]''', -); - -/// Validates that [folderName] is a safe container name. -/// -/// Throws [ArgumentError] if the name is empty, starts with a dot, or -/// contains characters that would be percent-encoded in a URL or are -/// otherwise unsafe for use as a directory name. - -void validateContainerName(String folderName) { - if (folderName.trim().isEmpty) { - throw ArgumentError('Folder name cannot be empty.'); - } - if (folderName.startsWith('.')) { - throw ArgumentError('Folder name cannot start with a dot.'); - } - final match = _invalidContainerNameChars.firstMatch(folderName); - if (match != null) { - final char = match.group(0); - final label = char == ' ' ? 'spaces' : '"$char"'; - throw ArgumentError( - 'Folder name cannot contain $label. ' - 'Avoid spaces and special characters: ' - r'/ \ # ? % & + @ = < > " | * : !', - ); - } -} - -/// Creates a new container (directory) on the POD from a relative path. -/// -/// Combines [parentPath] and [folderName] into a relative path, resolves -/// the full directory URL via [getDirUrl], and creates the container. -/// -/// [parentPath] is the normalised relative path to the parent directory -/// (e.g. `'myapp/data'` or `''` for the POD root). -/// -/// [folderName] is the name of the new directory to create. It must not -/// contain spaces or URL/filesystem-unsafe characters (see -/// [validateContainerName]). -/// -/// Throws [ArgumentError] if the name is invalid, or an [Exception] if -/// the directory already exists or a network error occurs. - -Future createContainer(String parentPath, String folderName) async { - // Validate the folder name before making any network calls. - - validateContainerName(folderName); - - // Combine parent path and folder name, handling empty parent (POD root). - - final folderPath = - parentPath.isEmpty ? folderName : '$parentPath/$folderName'; - final dirUrl = await getDirUrl(folderPath); - await createDir(dirUrl); -} - -/// Delete login information from the local storage -/// -/// returns true if successful - -Future deleteLogIn() async => AuthDataManager.removeAuthData(); - /// Set directory name for the app for storing the POD data /// /// If not initially set the app name will be taken by default. @@ -642,170 +140,6 @@ String getResNameFromUrl(String resourceUrl) { return resourceUrl.split('/').last; } -/// Get tokens necessary to fetch a resource from a POD -/// -/// returns the access token and DPoP token -Future<({String accessToken, String dPopToken})> getTokensForResource( - String resourceUrl, - String httpMethod, -) async { - final authData = await AuthDataManager.loadAuthData(); - - if (authData == null) { - throw Exception('Authentication data not available. Please login first.'); - } - - final rsaInfo = authData['rsaInfo']; - final rsaKeyPair = rsaInfo['rsa'] as KeyPair; - final publicKeyJwk = rsaInfo['pubKeyJwk']; - - return ( - accessToken: authData['accessToken'] as String, - dPopToken: genDpopToken(resourceUrl, rsaKeyPair, publicKeyJwk, httpMethod), - ); -} - -/// Logging out the user with comprehensive error handling and platform support -/// -/// This function performs a complete logout that includes: -/// 1. Clearing all encryption keys from memory -/// 2. Removing authentication data from secure storage -/// 3. Calling the OAuth2 logout endpoint (with error tolerance on web) -/// -/// Returns true if logout succeeds or critical operations complete, -/// false only if critical operations (key/auth cleanup) fail. -Future logoutPod() async { - try { - // Step 1: Clear all cached encryption keys and security data from memory - // This is CRITICAL and must be done regardless of other failures - await KeyManager.clear(); - debugPrint('logoutPod() => KeyManager.clear() completed'); - - // Step 2: Get the logout URL before removing auth data - final logoutUrl = await AuthDataManager.getLogoutUrl(); - - // Step 3: Remove authentication data from secure storage - // This is CRITICAL - must succeed - final authDataRemoved = await AuthDataManager.removeAuthData(); - if (!authDataRemoved) { - debugPrint( - 'logoutPod() => WARNING: AuthDataManager.removeAuthData() failed', - ); - // Don't return false yet - logout endpoint is still needed - } - - // Step 3.5: Clear application-specific caches BEFORE network call - // This is CRITICAL to prevent race conditions where UI reads stale cache - // during logout, especially when network is slow - if (_onLogoutClearCaches != null) { - try { - await _onLogoutClearCaches!(); - } on Object catch (e) { - debugPrint( - 'logoutPod() => WARNING: Application cache callback failed (non-critical): $e', - ); - // Continue - the critical auth data is already cleared - } - } else { - debugPrint('logoutPod() => No application cache callback registered'); - } - - // Step 4: Attempt OAuth2 logout - // This is OPTIONAL - should not block if it fails - if (logoutUrl != null && logoutUrl.isNotEmpty) { - try { - // Call the OAuth2 logout endpoint - // On web, this may fail with platform-related exceptions, but we continue anyway - await logout(logoutUrl); - debugPrint('logoutPod() => OAuth2 logout endpoint called successfully'); - } on Object catch (e) { - // On Flutter Web, platform-related exceptions might occur - // This is NOT a critical failure - the local session is already cleared - debugPrint('logoutPod() => OAuth2 logout warning (non-critical): $e'); - // Continue - local data is already cleared which is most important - } - } else { - debugPrint( - 'logoutPod() => No logout URL available, skipping OAuth2 logout', - ); - } - - // Success if we cleared the local data (most important part) - return authDataRemoved; - } on Object catch (e) { - // Catch any remaining exceptions - debugPrint('logoutPod() => CRITICAL ERROR: $e'); - // Even if we reach here, attempt to clear auth data as fallback - try { - await AuthDataManager.removeAuthData(); - await KeyManager.clear(); - } catch (fallbackError) { - debugPrint('logoutPod() => Fallback cleanup also failed: $fallbackError'); - } - return false; - } -} - -/// Clear all login state without opening a browser. -/// -/// Performs the same cleanup as [logoutPod] but invalidates the IdP session -/// via a headless HTTP request rather than launching a visible browser -/// window. Use this when switching accounts or recovering from stale -/// credentials. - -Future silentLogout() async { - try { - await KeyManager.clear(); - - final logoutUrl = await AuthDataManager.getLogoutUrl(); - final authDataRemoved = await AuthDataManager.removeAuthData(); - - if (_onLogoutClearCaches != null) { - try { - await _onLogoutClearCaches!(); - } on Object catch (e) { - debugPrint('silentLogout() cache callback failed (non-critical): $e'); - } - } - - // Best-effort IdP session invalidation via headless HTTP GET. - - if (logoutUrl != null && logoutUrl.isNotEmpty) { - try { - await http.get(Uri.parse(logoutUrl)); - } on Object catch (e) { - debugPrint('silentLogout() headless logout failed (non-critical): $e'); - } - } - - return authDataRemoved; - } on Object catch (e) { - debugPrint('silentLogout() CRITICAL: $e'); - try { - await AuthDataManager.removeAuthData(); - await KeyManager.clear(); - } on Object catch (_) {} - return false; - } -} - -/// Removes header and footer (which mess up the TTL format) from a PEM-formatted public key string. -/// -/// This function takes a public key string, typically in PEM format, and removes -/// the standard PEM headers and footers. - -String trimPubKeyStr(String keyStr) { - final itemList = keyStr.split('\n'); - itemList.remove('-----BEGIN RSA PUBLIC KEY-----'); - itemList.remove('-----END RSA PUBLIC KEY-----'); - itemList.remove('-----BEGIN PUBLIC KEY-----'); - itemList.remove('-----END PUBLIC KEY-----'); - - final keyStrTrimmed = itemList.join(); - - return keyStrTrimmed; -} - /// Get date and time from a string String getDateTime(String dateTimeStr) { final dateTime = DateTime.parse(dateTimeStr); @@ -813,107 +147,3 @@ String getDateTime(String dateTimeStr) { return dateFormat.format(dateTime); } - -/// Normalise file path for readPod/writePod operations. -/// -/// Handles backward compatibility by checking if the filePath already includes -/// the app directory prefix, and constructs the appropriate normalised path. -/// -/// When basePath is null (default for readPod/writePod), uses appname/data as base path. -/// -/// [filePath] - The input file path -/// [basePath] - The base path to use (defaults to appname/data when null) -/// -/// Returns the normalised file path. -/// -/// Examples: -/// - `normalizeFilePath('abc.ttl', null)` returns `appname/data/abc.ttl` -/// - `normalizeFilePath('movies/abc.ttl', null)` returns `appname/data/movies/abc.ttl` -/// - `normalizeFilePath('appname/data/keys.ttl', null)` returns `appname/data/keys.ttl` -/// -/// Note: Only `appname/data/` paths are supported for readPod/writePod operations. - -Future normalizeFilePath(String filePath, String? basePath) async { - // Normalise path separators for cross-platform compatibility. - - final normalizedInput = filePath.replaceAll(path.separator, '/'); - - // Use provided path or default to appname/data. - - final effectiveBasePath = basePath == null || basePath.trim().isEmpty - ? await getDataDirPath() - : basePath; - - // Check if path already starts with the correct base path (appname/data/). - - if (normalizedInput.startsWith(effectiveBasePath)) { - // Full path is already prepended (appname/data/). - - return normalizedInput; - } else { - // Prepend the base path. - - return [effectiveBasePath, normalizedInput].join('/'); - } -} - -/// Check if a given path string is a directory or not -bool isDir(String path) { - if (path.endsWith('/') || !path.contains('.')) { - return true; - } else { - return false; - } -} - -/// Generate the URL of resource according to its path and the type of the path. - -Future generateResourceUrlFromPath({ - required String resourcePath, - required PathType pathType, - bool isFile = true, - String? webId, -}) async { - final func = isFile ? getFileUrl : getDirUrl; - switch (pathType) { - case PathType.absoluteUrl: - return resourcePath; - - case PathType.relativeToPod: - return await func(resourcePath, webId: webId); - - case PathType.relativeToApp: - return await func([appDirName, resourcePath].join('/'), webId: webId); - - case PathType.relativeToData: - return await func( - [await getDataDirPath(), resourcePath].join('/'), - webId: webId, - ); - } -} - -/// Extract resource path from its URL -/// path format: -/// - appDir/path/to/file -/// - appDir/path/to/dir/ - -Future extractResourcePathFromUrl( - String resourceUrl, { - bool isFile = true, -}) async { - // See https://api.dart.dev/dart-core/Uri-class.html for details - - final segments = Uri.parse(resourceUrl).pathSegments; - - final path = segments.getRange(1, segments.length).join('/'); - - return !(isFile || path.endsWith('/')) ? '$path/' : path; -} - -/// Generate the Web ID of from resource URL - -Future generateWebIdFromResourceUrl(String resourceUrl) async { - final uri = Uri.parse(resourceUrl); - return [uri.origin, uri.pathSegments.first, profCard].join('/'); -} diff --git a/lib/src/solid/utils/misc_auth.dart b/lib/src/solid/utils/misc_auth.dart new file mode 100644 index 00000000..cafc11a3 --- /dev/null +++ b/lib/src/solid/utils/misc_auth.dart @@ -0,0 +1,243 @@ +/// Authentication / session-lifecycle helpers (login state, logout, tokens). +/// +/// Copyright (C) 2024, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Anushka Vidanage, Dawei Chen, Zheyuan Xu + +library; + +import 'package:flutter/foundation.dart' show debugPrint; + +import 'package:fast_rsa/fast_rsa.dart' show KeyPair; +import 'package:http/http.dart' as http; +import 'package:jwt_decoder/jwt_decoder.dart'; +import 'package:solid_auth/solid_auth.dart' show genDpopToken, logout; + +import 'package:solidpod/src/solid/utils/authdata_manager.dart'; +import 'package:solidpod/src/solid/utils/key_manager.dart'; + +/// Global callback for clearing application-specific caches during logout. +/// Apps should register their cache clearing logic here. +/// This ensures caches are cleared BEFORE any blocking network operations. +Future Function()? _onLogoutClearCaches; + +/// Register a callback to clear application-specific caches during logout. +/// This callback will be invoked BEFORE the OAuth2 logout endpoint call, +/// preventing race conditions where cached data might be visible during logout. +void registerLogoutCacheCallback(Future Function() callback) { + _onLogoutClearCaches = callback; +} + +/// Return the web ID +Future getWebId() async => AuthDataManager.getWebId(); + +/// Check whether a user is logged in or not +/// +/// Check if the local storage has authentication +/// details of the user and also check whether the +/// access token is expired or not + +Future isUserLoggedIn() async { + final webId = await AuthDataManager.getWebId(); + + if (webId != null && webId.isNotEmpty) { + final accessToken = await AuthDataManager.getAccessToken(); + if (accessToken != null && !JwtDecoder.isExpired(accessToken)) { + return true; + } + } + + return false; +} + +/// Delete login information from the local storage +/// +/// returns true if successful + +Future deleteLogIn() async => AuthDataManager.removeAuthData(); + +/// Get tokens necessary to fetch a resource from a POD +/// +/// returns the access token and DPoP token +Future<({String accessToken, String dPopToken})> getTokensForResource( + String resourceUrl, + String httpMethod, +) async { + final authData = await AuthDataManager.loadAuthData(); + + if (authData == null) { + throw Exception('Authentication data not available. Please login first.'); + } + + final rsaInfo = authData['rsaInfo']; + final rsaKeyPair = rsaInfo['rsa'] as KeyPair; + final publicKeyJwk = rsaInfo['pubKeyJwk']; + + return ( + accessToken: authData['accessToken'] as String, + dPopToken: genDpopToken(resourceUrl, rsaKeyPair, publicKeyJwk, httpMethod), + ); +} + +/// Logging out the user with comprehensive error handling and platform support +/// +/// This function performs a complete logout that includes: +/// 1. Clearing all encryption keys from memory +/// 2. Removing authentication data from secure storage +/// 3. Calling the OAuth2 logout endpoint (with error tolerance on web) +/// +/// Returns true if logout succeeds or critical operations complete, +/// false only if critical operations (key/auth cleanup) fail. +Future logoutPod() async { + try { + // Step 1: Clear all cached encryption keys and security data from memory + // This is CRITICAL and must be done regardless of other failures + await KeyManager.clear(); + debugPrint('logoutPod() => KeyManager.clear() completed'); + + // Step 2: Get the logout URL before removing auth data + final logoutUrl = await AuthDataManager.getLogoutUrl(); + + // Step 3: Remove authentication data from secure storage + // This is CRITICAL - must succeed + final authDataRemoved = await AuthDataManager.removeAuthData(); + if (!authDataRemoved) { + debugPrint( + 'logoutPod() => WARNING: AuthDataManager.removeAuthData() failed', + ); + // Don't return false yet - logout endpoint is still needed + } + + // Step 3.5: Clear application-specific caches BEFORE network call + // This is CRITICAL to prevent race conditions where UI reads stale cache + // during logout, especially when network is slow + if (_onLogoutClearCaches != null) { + try { + await _onLogoutClearCaches!(); + } on Object catch (e) { + debugPrint( + 'logoutPod() => WARNING: Application cache callback failed (non-critical): $e', + ); + // Continue - the critical auth data is already cleared + } + } else { + debugPrint('logoutPod() => No application cache callback registered'); + } + + // Step 4: Attempt OAuth2 logout + // This is OPTIONAL - should not block if it fails + if (logoutUrl != null && logoutUrl.isNotEmpty) { + try { + // Call the OAuth2 logout endpoint + // On web, this may fail with platform-related exceptions, but we continue anyway + await logout(logoutUrl); + debugPrint('logoutPod() => OAuth2 logout endpoint called successfully'); + } on Object catch (e) { + // On Flutter Web, platform-related exceptions might occur + // This is NOT a critical failure - the local session is already cleared + debugPrint('logoutPod() => OAuth2 logout warning (non-critical): $e'); + // Continue - local data is already cleared which is most important + } + } else { + debugPrint( + 'logoutPod() => No logout URL available, skipping OAuth2 logout', + ); + } + + // Success if we cleared the local data (most important part) + return authDataRemoved; + } on Object catch (e) { + // Catch any remaining exceptions + debugPrint('logoutPod() => CRITICAL ERROR: $e'); + // Even if we reach here, attempt to clear auth data as fallback + try { + await AuthDataManager.removeAuthData(); + await KeyManager.clear(); + } catch (fallbackError) { + debugPrint('logoutPod() => Fallback cleanup also failed: $fallbackError'); + } + return false; + } +} + +/// Clear all login state without opening a browser. +/// +/// Performs the same cleanup as [logoutPod] but invalidates the IdP session +/// via a headless HTTP request rather than launching a visible browser +/// window. Use this when switching accounts or recovering from stale +/// credentials. + +Future silentLogout() async { + try { + await KeyManager.clear(); + + final logoutUrl = await AuthDataManager.getLogoutUrl(); + final authDataRemoved = await AuthDataManager.removeAuthData(); + + if (_onLogoutClearCaches != null) { + try { + await _onLogoutClearCaches!(); + } on Object catch (e) { + debugPrint('silentLogout() cache callback failed (non-critical): $e'); + } + } + + // Best-effort IdP session invalidation via headless HTTP GET. + + if (logoutUrl != null && logoutUrl.isNotEmpty) { + try { + await http.get(Uri.parse(logoutUrl)); + } on Object catch (e) { + debugPrint('silentLogout() headless logout failed (non-critical): $e'); + } + } + + return authDataRemoved; + } on Object catch (e) { + debugPrint('silentLogout() CRITICAL: $e'); + try { + await AuthDataManager.removeAuthData(); + await KeyManager.clear(); + } on Object catch (_) {} + return false; + } +} + +/// Removes header and footer (which mess up the TTL format) from a PEM-formatted public key string. +/// +/// This function takes a public key string, typically in PEM format, and removes +/// the standard PEM headers and footers. + +String trimPubKeyStr(String keyStr) { + final itemList = keyStr.split('\n'); + itemList.remove('-----BEGIN RSA PUBLIC KEY-----'); + itemList.remove('-----END RSA PUBLIC KEY-----'); + itemList.remove('-----BEGIN PUBLIC KEY-----'); + itemList.remove('-----END PUBLIC KEY-----'); + + final keyStrTrimmed = itemList.join(); + + return keyStrTrimmed; +} diff --git a/lib/src/solid/utils/misc_container.dart b/lib/src/solid/utils/misc_container.dart new file mode 100644 index 00000000..62d4c82d --- /dev/null +++ b/lib/src/solid/utils/misc_container.dart @@ -0,0 +1,107 @@ +/// Helpers for creating and validating POD container (directory) resources. +/// +/// Copyright (C) 2024, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Anushka Vidanage, Dawei Chen, Zheyuan Xu + +library; + +import 'package:solidpod/src/solid/api/rest_api.dart'; +import 'package:solidpod/src/solid/constants/common.dart'; +import 'package:solidpod/src/solid/utils/get_url_helper.dart'; + +/// Create a directory with the given URL. + +Future createDir(String dirUrl) async { + assert(dirUrl.endsWith('/')); + await createResource( + dirUrl, + isFile: false, + replaceIfExist: false, + contentType: ResourceContentType.directory, + ); +} + +/// Characters that are forbidden in container (folder) names. +/// +/// These characters are either URL-unsafe (causing percent-encoding issues +/// such as spaces becoming `%20`) or filesystem-unsafe on common platforms. + +final RegExp _invalidContainerNameChars = RegExp( + r'''[ /#?%&+@=<>"|*:!\\]''', +); + +/// Validates that [folderName] is a safe container name. +/// +/// Throws [ArgumentError] if the name is empty, starts with a dot, or +/// contains characters that would be percent-encoded in a URL or are +/// otherwise unsafe for use as a directory name. + +void validateContainerName(String folderName) { + if (folderName.trim().isEmpty) { + throw ArgumentError('Folder name cannot be empty.'); + } + if (folderName.startsWith('.')) { + throw ArgumentError('Folder name cannot start with a dot.'); + } + final match = _invalidContainerNameChars.firstMatch(folderName); + if (match != null) { + final char = match.group(0); + final label = char == ' ' ? 'spaces' : '"$char"'; + throw ArgumentError( + 'Folder name cannot contain $label. ' + 'Avoid spaces and special characters: ' + r'/ \ # ? % & + @ = < > " | * : !', + ); + } +} + +/// Creates a new container (directory) on the POD from a relative path. +/// +/// Combines [parentPath] and [folderName] into a relative path, resolves +/// the full directory URL via [getDirUrl], and creates the container. +/// +/// [parentPath] is the normalised relative path to the parent directory +/// (e.g. `'myapp/data'` or `''` for the POD root). +/// +/// [folderName] is the name of the new directory to create. It must not +/// contain spaces or URL/filesystem-unsafe characters (see +/// [validateContainerName]). +/// +/// Throws [ArgumentError] if the name is invalid, or an [Exception] if +/// the directory already exists or a network error occurs. + +Future createContainer(String parentPath, String folderName) async { + // Validate the folder name before making any network calls. + + validateContainerName(folderName); + + // Combine parent path and folder name, handling empty parent (POD root). + + final folderPath = + parentPath.isEmpty ? folderName : '$parentPath/$folderName'; + final dirUrl = await getDirUrl(folderPath); + await createDir(dirUrl); +} diff --git a/lib/src/solid/utils/misc_encryption.dart b/lib/src/solid/utils/misc_encryption.dart new file mode 100644 index 00000000..a3c56569 --- /dev/null +++ b/lib/src/solid/utils/misc_encryption.dart @@ -0,0 +1,293 @@ +/// Encryption / decryption helpers operating on POD resources. +/// +/// Copyright (C) 2024, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Anushka Vidanage, Dawei Chen, Zheyuan Xu + +library; + +import 'dart:convert' show utf8; + +import 'package:flutter/foundation.dart' show debugPrint; + +import 'package:encrypter_plus/encrypter_plus.dart'; +import 'package:rdflib/rdflib.dart'; + +import 'package:solidpod/src/solid/api/rest_api.dart'; +import 'package:solidpod/src/solid/constants/common.dart'; +import 'package:solidpod/src/solid/constants/schema.dart'; +import 'package:solidpod/src/solid/public_sharing_hooks.dart'; +import 'package:solidpod/src/solid/utils/data_encryption.dart'; +import 'package:solidpod/src/solid/utils/key_manager.dart'; +import 'package:solidpod/src/solid/utils/misc_paths.dart'; +import 'package:solidpod/src/solid/utils/rdf.dart'; + +/// Encrypt a given data string and format to TTL +Future getEncTTLStr({ + required String fileUrl, + required String fileContent, + required Key key, + required IV iv, + String? inheritKeyFrom, +}) async { + final filePath = await extractResourcePathFromUrl(fileUrl); + final triples = { + URIRef(fileUrl): { + solidTermsNS.ns.withAttr(pathPred): filePath, + solidTermsNS.ns.withAttr(ivPred): iv.base64, + if (inheritKeyFrom != null) + solidTermsNS.ns.withAttr(inheritKeyPred): inheritKeyFrom, + solidTermsNS.ns.withAttr(encDataPred): encryptData(fileContent, key, iv), + }, + }; + + final bindNS = {solidTermsNS.prefix: solidTermsNS.ns}; + + return tripleMapToTurtle(triples, bindNamespaces: bindNS); +} + +/// Inspect the on-server content of [fileUrl] and determine whether it is +/// currently in the encrypted TTL format produced by [getEncTTLStr]. + +Future isFileContentEncrypted(String fileUrl) async { + if (!fileUrl.toLowerCase().endsWith('.ttl')) { + debugPrint('[isFileContentEncrypted] non-ttl url, returning false: ' + '"$fileUrl"'); + return false; + } + try { + if (await checkResourceStatus(fileUrl) != ResourceStatus.exist) { + debugPrint('[isFileContentEncrypted] resource does not exist: ' + '"$fileUrl"'); + return false; + } + final raw = utf8.decode(await getResource(fileUrl)); + final encMap = _extractEncFields(raw, fileUrl); + if (encMap == null) { + debugPrint( + '[isFileContentEncrypted] no iv/encData found for "$fileUrl"', + ); + return false; + } + debugPrint('[isFileContentEncrypted] file IS encrypted: "$fileUrl"'); + return true; + } on Object catch (e) { + debugPrint('[isFileContentEncrypted] unable to inspect "$fileUrl": $e'); + return false; + } +} + +/// Look at the triple map produced by [turtleToTripleMap] and return the +/// `(iv, encData)` pair for [fileUrl] if the document is in the encrypted +/// TTL format, or `null` if it is not. + +({String iv, String encData})? _extractEncFields( + String rawTtl, + String fileUrl, +) { + final tripleMap = turtleToTripleMap(rawTtl); + final ivKey = solidTermsNS.ns.withAttr(ivPred).value; + final encKey = solidTermsNS.ns.withAttr(encDataPred).value; + + ({String iv, String encData})? pickFrom(Map m) { + final iv = m[ivKey]; + final enc = m[encKey]; + if (iv is String && enc is String) { + return (iv: iv, encData: enc); + } + return null; + } + + final direct = tripleMap[fileUrl]; + if (direct != null) { + final r = pickFrom(direct); + if (r != null) return r; + } + + for (final entry in tripleMap.entries) { + if (entry.key == fileUrl) continue; + final r = pickFrom(entry.value); + if (r != null) { + debugPrint( + '[isFileContentEncrypted] iv/encData found under fallback subject ' + '"${entry.key}" instead of "$fileUrl"', + ); + return r; + } + } + + return null; +} + +/// Decrypt the content of [fileUrl] in place on the server, replacing the +/// encrypted TTL payload with the plaintext that was originally written. + +Future decryptFileInPlace( + String fileUrl, { + bool isExternalRes = false, +}) async { + debugPrint('[decryptFileInPlace] start url="$fileUrl" ' + 'isExternalRes=$isExternalRes'); + final raw = utf8.decode(await getResource(fileUrl)); + final encMap = _extractEncFields(raw, fileUrl); + + if (encMap == null) { + debugPrint( + '[decryptFileInPlace] file is not in encrypted format, nothing to do: ' + '"$fileUrl"', + ); + return; + } + + final indKey = isExternalRes + ? await KeyManager.getSharedIndividualKey(fileUrl) + : await KeyManager.getIndividualKey(fileUrl); + + if (indKey == null) { + throw Exception( + 'No individual encryption key available for "$fileUrl"; ' + 'cannot decrypt the file for public/authenticated-user sharing.', + ); + } + + var plaintext = decryptData( + encMap.encData, + indKey, + IV.fromBase64(encMap.iv), + ); + + // Allow the host app to strip an additional application-level + // encryption layer before the file is exposed publicly. + + final postHook = PublicSharingHooks.onPublicShareDecrypted; + if (postHook != null) { + debugPrint( + '[decryptFileInPlace] applying app onPublicShareDecrypted hook', + ); + plaintext = await postHook(fileUrl, plaintext); + } + + await createResource( + fileUrl, + content: plaintext, + contentType: ResourceContentType.turtleText, + ); + debugPrint('[decryptFileInPlace] wrote plaintext (${plaintext.length} ' + 'bytes) to "$fileUrl"'); +} + +/// Apply the [PublicSharingHooks.onPublicShareDecrypted] transformer to +/// the current (already-plaintext) bytes of [fileUrl] and write back any +/// resulting change. + +Future applyPublicShareDecryptedHookInPlace(String fileUrl) async { + final hook = PublicSharingHooks.onPublicShareDecrypted; + if (hook == null) { + debugPrint( + '[applyPublicShareDecryptedHookInPlace] no hook registered, ' + 'nothing to do: "$fileUrl"', + ); + return; + } + + try { + final current = utf8.decode(await getResource(fileUrl)); + final transformed = await hook(fileUrl, current); + if (transformed == current) { + debugPrint( + '[applyPublicShareDecryptedHookInPlace] hook left content ' + 'unchanged: "$fileUrl"', + ); + return; + } + await createResource( + fileUrl, + content: transformed, + contentType: ResourceContentType.turtleText, + ); + debugPrint( + '[applyPublicShareDecryptedHookInPlace] hook rewrote content ' + '(${transformed.length} bytes) for "$fileUrl"', + ); + } on Object catch (e) { + debugPrint( + '[applyPublicShareDecryptedHookInPlace] hook failed for "$fileUrl": $e', + ); + } +} + +/// Encrypt the (currently plaintext) content of [fileUrl] in place using +/// the individual encryption key already recorded for the resource. + +Future encryptFileInPlace( + String fileUrl, { + bool isExternalRes = false, +}) async { + debugPrint('[encryptFileInPlace] start url="$fileUrl" ' + 'isExternalRes=$isExternalRes'); + final indKey = isExternalRes + ? await KeyManager.getSharedIndividualKey(fileUrl) + : await KeyManager.getIndividualKey(fileUrl); + if (indKey == null) { + debugPrint('[encryptFileInPlace] no individual key for "$fileUrl", ' + 'leaving file as plaintext'); + return; + } + + if (await isFileContentEncrypted(fileUrl)) { + debugPrint('[encryptFileInPlace] file already encrypted, nothing to do: ' + '"$fileUrl"'); + return; + } + + var plaintext = utf8.decode(await getResource(fileUrl)); + + // Give the host app a chance to restore an inner application-level + // encryption layer that was peeled off by [decryptFileInPlace] when + // public/auth-user sharing was granted, so that at-rest state is + // symmetric with what the app originally wrote via [writePod]. + + final preHook = PublicSharingHooks.onPublicShareRevoked; + if (preHook != null) { + debugPrint( + '[encryptFileInPlace] applying app onPublicShareRevoked hook', + ); + plaintext = await preHook(fileUrl, plaintext); + } + + final encContent = await getEncTTLStr( + fileUrl: fileUrl, + fileContent: plaintext, + key: indKey, + iv: IV.fromLength(16), + ); + + await createResource( + fileUrl, + content: encContent, + contentType: ResourceContentType.turtleText, + ); + debugPrint('[encryptFileInPlace] re-encrypted "$fileUrl"'); +} diff --git a/lib/src/solid/utils/misc_paths.dart b/lib/src/solid/utils/misc_paths.dart new file mode 100644 index 00000000..37c47539 --- /dev/null +++ b/lib/src/solid/utils/misc_paths.dart @@ -0,0 +1,226 @@ +/// Path-related helpers used across the package. +/// +/// Copyright (C) 2024, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Anushka Vidanage, Dawei Chen, Zheyuan Xu + +library; + +import 'package:flutter/foundation.dart' show debugPrint; + +import 'package:path/path.dart' as path; + +import 'package:solidpod/src/solid/constants/common.dart'; +import 'package:solidpod/src/solid/constants/path_type.dart'; +import 'package:solidpod/src/solid/utils/get_url_helper.dart'; + +/// Returns the path of file with verification key and private key +Future getEncKeyPath() async => + [appDirName, encDir, encKeyFile].join('/'); + +/// Returns the path of file with individual keys +Future getIndKeyPath() async => + [appDirName, encDir, indKeyFile].join('/'); + +/// Returns the path of file with public keys +Future getPubKeyPath() async => + [appDirName, sharingDir, pubKeyFile].join('/'); + +/// Returns the path of public file with individual keys +Future getPubIndKeyPath() async => + [appDirName, sharingDir, pubIndKeyFile].join('/'); + +/// Returns the path of file with individual keys accessed only +/// by authenticated users +Future getAuthUserIndKeyPath() async => + [appDirName, sharingDir, authUserIndKeyFile].join('/'); + +/// Returns the path of the data directory +Future getDataDirPath() async => [appDirName, dataDir].join('/'); + +/// Returns the path of the shared directory +Future getSharedDirPath() async => [appDirName, sharedDir].join('/'); + +/// Returns the path of the file with shared individual keys +Future getSharedKeyFilePath() async => + [appDirName, sharedDir, sharedKeyFile].join('/'); + +/// Returns the path of the encryption directory +Future getEncDirPath() async => [appDirName, encDir].join('/'); + +/// Returns the path of the encryption directory +Future getPermLogFilePath() async => + [appDirName, logsDir, permLogFile].join('/'); + +/// Checks whether a POD-relative [resourcePath] falls within the current +/// application's directory tree. +/// +/// Returns `true` if the resource belongs to this app, meaning the app +/// holds the encryption key required to decrypt it. Returns `false` if +/// the resource belongs to another application's folder, in which case +/// decryption may not be possible. +/// +/// [resourcePath] should be a normalised POD-relative path (e.g. +/// `myapp/data/file.ttl`). Absolute URLs or empty strings return `false`. + +Future isPathInCurrentApp(String resourcePath) async { + try { + if (resourcePath.trim().isEmpty) return false; + + if (resourcePath.startsWith('http://') || + resourcePath.startsWith('https://')) { + debugPrint( + 'isPathInCurrentApp: expected a POD-relative path but received ' + 'an absolute URL: $resourcePath', + ); + return false; + } + + // Derive the current app name from getDataDirPath() which returns + // "APP_NAME/data". The first segment is the app name. + + final appDataPath = await getDataDirPath(); + if (appDataPath.isEmpty) return false; + + final currentAppName = appDataPath.split('/').first; + if (currentAppName.isEmpty) return false; + + // Build full URLs for both the resource and the app root, then + // compare prefixes. getDirUrl appends a trailing slash which + // prevents false positives (e.g. "myapp2" matching "myapp"). + + final resourceUrl = await getFileUrl(resourcePath); + final appRootUrl = await getDirUrl(currentAppName); + + return resourceUrl.startsWith(appRootUrl); + } catch (e) { + debugPrint('Error in isPathInCurrentApp: $e'); + return false; + } +} + +/// Normalise file path for readPod/writePod operations. +/// +/// Handles backward compatibility by checking if the filePath already includes +/// the app directory prefix, and constructs the appropriate normalised path. +/// +/// When basePath is null (default for readPod/writePod), uses appname/data as base path. +/// +/// [filePath] - The input file path +/// [basePath] - The base path to use (defaults to appname/data when null) +/// +/// Returns the normalised file path. +/// +/// Examples: +/// - `normalizeFilePath('abc.ttl', null)` returns `appname/data/abc.ttl` +/// - `normalizeFilePath('movies/abc.ttl', null)` returns `appname/data/movies/abc.ttl` +/// - `normalizeFilePath('appname/data/keys.ttl', null)` returns `appname/data/keys.ttl` +/// +/// Note: Only `appname/data/` paths are supported for readPod/writePod operations. + +Future normalizeFilePath(String filePath, String? basePath) async { + // Normalise path separators for cross-platform compatibility. + + final normalizedInput = filePath.replaceAll(path.separator, '/'); + + // Use provided path or default to appname/data. + + final effectiveBasePath = basePath == null || basePath.trim().isEmpty + ? await getDataDirPath() + : basePath; + + // Check if path already starts with the correct base path (appname/data/). + + if (normalizedInput.startsWith(effectiveBasePath)) { + // Full path is already prepended (appname/data/). + + return normalizedInput; + } else { + // Prepend the base path. + + return [effectiveBasePath, normalizedInput].join('/'); + } +} + +/// Check if a given path string is a directory or not +bool isDir(String path) { + if (path.endsWith('/') || !path.contains('.')) { + return true; + } else { + return false; + } +} + +/// Generate the URL of resource according to its path and the type of the path. + +Future generateResourceUrlFromPath({ + required String resourcePath, + required PathType pathType, + bool isFile = true, + String? webId, +}) async { + final func = isFile ? getFileUrl : getDirUrl; + switch (pathType) { + case PathType.absoluteUrl: + return resourcePath; + + case PathType.relativeToPod: + return await func(resourcePath, webId: webId); + + case PathType.relativeToApp: + return await func([appDirName, resourcePath].join('/'), webId: webId); + + case PathType.relativeToData: + return await func( + [await getDataDirPath(), resourcePath].join('/'), + webId: webId, + ); + } +} + +/// Extract resource path from its URL +/// path format: +/// - appDir/path/to/file +/// - appDir/path/to/dir/ + +Future extractResourcePathFromUrl( + String resourceUrl, { + bool isFile = true, +}) async { + // See https://api.dart.dev/dart-core/Uri-class.html for details + + final segments = Uri.parse(resourceUrl).pathSegments; + + final path = segments.getRange(1, segments.length).join('/'); + + return !(isFile || path.endsWith('/')) ? '$path/' : path; +} + +/// Generate the Web ID of from resource URL + +Future generateWebIdFromResourceUrl(String resourceUrl) async { + final uri = Uri.parse(resourceUrl); + return [uri.origin, uri.pathSegments.first, profCard].join('/'); +} From 128056282931c1f3d23c2c9684dc9a8c33a75081 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 27 May 2026 00:06:06 +1000 Subject: [PATCH 3/3] Lint (unused code) --- lib/src/solid/api/grant_permission_api.dart | 61 --------------------- 1 file changed, 61 deletions(-) diff --git a/lib/src/solid/api/grant_permission_api.dart b/lib/src/solid/api/grant_permission_api.dart index 94c33157..2121d7f5 100644 --- a/lib/src/solid/api/grant_permission_api.dart +++ b/lib/src/solid/api/grant_permission_api.dart @@ -34,7 +34,6 @@ library; import 'dart:convert'; -import 'package:encrypter_plus/encrypter_plus.dart'; import 'package:rdflib/rdflib.dart'; import 'package:solidpod/src/solid/api/rest_api.dart'; @@ -250,63 +249,3 @@ Future copySharedKey( } } } - -/// Copy shared individual key, either publicly or for all authenticated users -Future copySharedKeyUserClass( - Key indKey, - String resourceUrl, - List permissionList, - RecipientType recipientType, -) async { - // File contents variables - var userClassIndKeyFileUrl = ''; - var aclContentStr = ''; - - if (recipientType == RecipientType.public) { - // Get the url of the file - userClassIndKeyFileUrl = await getFileUrl(await getPubIndKeyPath()); - - // Create ACL content for the file - aclContentStr = await genAclTurtle( - userClassIndKeyFileUrl, - ownerAccess: {AccessMode.read, AccessMode.write, AccessMode.control}, - publicAccess: {AccessMode.read}, - ); - } else if (recipientType == RecipientType.authUser) { - // Get the url of the file - userClassIndKeyFileUrl = await getFileUrl(await getAuthUserIndKeyPath()); - - // Create ACL content for the file - aclContentStr = await genAclTurtle( - userClassIndKeyFileUrl, - ownerAccess: {AccessMode.read, AccessMode.write, AccessMode.control}, - authUserAccess: {AccessMode.read}, - ); - } - - // Check if individual key file exists. If not create a file - if (await checkResourceStatus(userClassIndKeyFileUrl, isFile: true) == - ResourceStatus.notExist) { - // If file does not exist create a ttl file - final userClassIndKeyFileContent = await genUserClassIndKeyTTLStr([ - resourceUrl, - indKey.base64, - ]); - - await createResource( - userClassIndKeyFileUrl, - content: userClassIndKeyFileContent, - ); - - // Also create a corresponding acl file - await createResource('$userClassIndKeyFileUrl.acl', content: aclContentStr); - } else { - // Update the existing file using a sparql query - final prefix = '${solidTermsNS.prefix}: <$appsTerms>'; - final insertQuery = - 'PREFIX $prefix INSERT DATA {<$resourceUrl> ${solidTermsNS.prefix}:encryptionKey "${indKey.base64}"};'; - - // Update the file using the insert query - await updateFileByQuery(userClassIndKeyFileUrl, insertQuery); - } -}