diff --git a/example/lib/features/create_acl_inherited_file.dart b/example/lib/features/create_acl_inherited_file.dart index 61c996ac..24d687cd 100644 --- a/example/lib/features/create_acl_inherited_file.dart +++ b/example/lib/features/create_acl_inherited_file.dart @@ -25,10 +25,10 @@ library; import 'package:flutter/material.dart'; -import 'package:demopod/constants/app.dart'; - import 'package:solidpod/solidpod.dart' show writePod, setInheritKeyDir; +import 'package:demopod/constants/app.dart'; + // A widget to create a resource with inherited ACL. // // The resource will be created inside a parent directory and the ACL of that diff --git a/example/lib/features/edit_keyvalue.dart b/example/lib/features/edit_keyvalue.dart index ba406fa0..02bdb119 100644 --- a/example/lib/features/edit_keyvalue.dart +++ b/example/lib/features/edit_keyvalue.dart @@ -27,13 +27,13 @@ library; import 'package:flutter/material.dart'; -import 'package:demopod/constants/app.dart'; -import 'package:demopod/dialogs/alert.dart'; -import 'package:demopod/utils/rdf.dart'; import 'package:editable/editable.dart'; +import 'package:solidpod/solidpod.dart' show isUserLoggedIn, writePod; import 'package:solidui/solidui.dart' show getKeyFromUserIfRequired; -import 'package:solidpod/solidpod.dart' show isUserLoggedIn, writePod; +import 'package:demopod/constants/app.dart'; +import 'package:demopod/dialogs/alert.dart'; +import 'package:demopod/utils/rdf.dart'; class KeyValueEdit extends StatefulWidget { /// Constructor diff --git a/example/lib/features/file_service.dart b/example/lib/features/file_service.dart index 8147eb27..593728b3 100644 --- a/example/lib/features/file_service.dart +++ b/example/lib/features/file_service.dart @@ -25,11 +25,11 @@ library; import 'package:flutter/material.dart'; -import 'package:demopod/dialogs/alert.dart'; import 'package:file_picker/file_picker.dart'; - import 'package:solidpod/solidpod.dart'; +import 'package:demopod/dialogs/alert.dart'; + class FileService extends StatefulWidget { const FileService({required this.child, required this.webId, super.key}); final String webId; diff --git a/example/lib/features/view_keys.dart b/example/lib/features/view_keys.dart index bb0a88d8..03a9ba76 100644 --- a/example/lib/features/view_keys.dart +++ b/example/lib/features/view_keys.dart @@ -27,11 +27,11 @@ library; import 'package:flutter/material.dart'; +import 'package:solidpod/solidpod.dart' show KeyManager; + import 'package:demopod/constants/app.dart'; import 'package:demopod/utils/rdf.dart' show getEncKeyContent; -import 'package:solidpod/solidpod.dart' show KeyManager; - /// A widget to show the user all the encryption keys stored in their Solid Pod. class ViewKeys extends StatefulWidget { diff --git a/example/lib/main.dart b/example/lib/main.dart index 46935571..9346ef00 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -27,11 +27,12 @@ library; import 'package:flutter/material.dart'; -import 'package:demopod/home.dart'; -import 'package:demopod/utils/is_desktop.dart'; import 'package:solidui/solidui.dart' show SolidLogin, InfoButtonStyle; import 'package:window_manager/window_manager.dart'; +import 'package:demopod/home.dart'; +import 'package:demopod/utils/is_desktop.dart'; + void main() async { // Remove [debugPrint] messages from production code. diff --git a/example/lib/utils/rdf.dart b/example/lib/utils/rdf.dart index 08d27523..0c8dedb1 100644 --- a/example/lib/utils/rdf.dart +++ b/example/lib/utils/rdf.dart @@ -25,7 +25,6 @@ library; import 'package:rdflib/rdflib.dart'; - import 'package:solidpod/solidpod.dart' show getWebId; // Namespace for keys diff --git a/lib/src/solid/authenticate.dart b/lib/src/solid/authenticate.dart index ef23b4c5..2de4138b 100644 --- a/lib/src/solid/authenticate.dart +++ b/lib/src/solid/authenticate.dart @@ -60,6 +60,10 @@ final List _scopes = [ /// /// [context] of the current widget is required for the authenticate process. /// +/// [wasAlreadyLoggedIn] is an optional pre-computed login status. When provided +/// it avoids a redundant call to [isUserLoggedIn] (and the associated secure +/// storage read + token-expiry check) that the caller may have already done. +/// /// Return a list containing authentication data: user's webId; profile data. /// /// Error Handling: The function has a catch all to return null if any exception @@ -67,16 +71,17 @@ final List _scopes = [ Future?> solidAuthenticate( String serverId, - BuildContext context, -) async { + BuildContext context, { + bool? wasAlreadyLoggedIn, +}) async { try { - final loggedIn = await isUserLoggedIn(); + // Use the caller-supplied value when available to avoid a redundant + // isUserLoggedIn() call (secure storage read + possible token refresh). + final loggedIn = wasAlreadyLoggedIn ?? await isUserLoggedIn(); Map? authData; if (loggedIn) { authData = await AuthDataManager.loadAuthData(); - if (authData == null) { - // Fall through to re-authenticate - } + // authData == null means refresh failed; fall through to re-authenticate } // If not logged in or load failed, perform new authentication @@ -112,22 +117,36 @@ Future?> solidAuthenticate( return null; } - // Proceed to fetch profile data with the authenticated credentials + // Fetch profile data. When the user entered a WebID URL (profile/card#me) + // as the server ID, [getIssuer] already fetched the profile document to + // extract the OIDC issuer URI. Reuse that cached body to avoid a second + // HTTP GET to the same URL. final profCardUrl = webId.replaceAll('#me', ''); - final profData = utf8.decode(await getResource(profCardUrl)); + var profData = getCachedIssuerProfileBody(profCardUrl); + // If the issuer was resolved from a plain URI (not a WebID profile URL), + // the profile body was not pre-fetched. Fetch it now with the + // authenticated access token. + profData ??= utf8.decode(await getResource(profCardUrl)); + AuthDataManager.setCachedProfData(profData); return [authData, webId, profData]; } - // Already logged in successfully - fetch profile data + // Already logged in successfully - return cached or freshly-fetched profile. final webId = await AuthDataManager.getWebId(); if (webId == null || webId.isEmpty) { await logoutPod(); return null; } + // Use the in-memory profile cache to avoid an unnecessary HTTP request on + // every cached-session login. final profCardUrl = webId.replaceAll('#me', ''); - final profData = utf8.decode(await getResource(profCardUrl)); + var profData = AuthDataManager.getCachedProfData(); + if (profData == null) { + profData = utf8.decode(await getResource(profCardUrl)); + AuthDataManager.setCachedProfData(profData); + } return [authData, webId, profData]; } on Object catch (e) { diff --git a/lib/src/solid/utils/authdata_manager.dart b/lib/src/solid/utils/authdata_manager.dart index cd07a6b6..75a778b6 100644 --- a/lib/src/solid/utils/authdata_manager.dart +++ b/lib/src/solid/utils/authdata_manager.dart @@ -66,6 +66,21 @@ class AuthDataManager { /// The authentication response static Credential? _authResponse; + /// In-memory cache for the last valid [TokenResponse]. + /// Avoids repeated JSON deserialization and JWT decoding on every + /// [getTokensForResource] call (e.g. the 13 parallel Pod structure checks). + static TokenResponse? _cachedTokenResponse; + + /// In-memory cache for the user's profile card (Turtle/RDF string). + /// Avoids re-fetching on every cached-session login. + static String? _cachedProfData; + + /// Returns the cached profile card data, or null if not yet fetched. + static String? getCachedProfData() => _cachedProfData; + + /// Stores profile card data in the in-memory cache. + static void setCachedProfData(String data) => _cachedProfData = data; + /// The string key for storing auth data in secure storage static const String _authDataSecureStorageKey = '_solid_auth_data'; @@ -95,6 +110,7 @@ class AuthDataManager { _rsaInfo = authData['rsaInfo'] as Map; // Note that use Map does not seem to work _authResponse = authData['authResponse'] as Credential; + _cachedTokenResponse = authData['tokenResponse'] as TokenResponse; await writeToSecureStorage( _authDataSecureStorageKey, @@ -165,6 +181,10 @@ class AuthDataManager { _rsaInfo = null; _authResponse = null; } + _cachedTokenResponse = null; + + // Clear in-memory profile cache on logout. + _cachedProfData = null; // Notify listeners that auth state has changed authStateNotifier.value = false; @@ -189,6 +209,13 @@ class AuthDataManager { /// Returns the (updated) token response static Future _getTokenResponse() async { + // Fast path: return cached token if still valid, skipping JSON + // deserialization and JWT decoding on every call. + if (_cachedTokenResponse?.accessToken != null && + !JwtDecoder.isExpired(_cachedTokenResponse!.accessToken!)) { + return _cachedTokenResponse; + } + if (_authResponse == null) { final loaded = await _loadData(); if (!loaded) { @@ -221,6 +248,7 @@ class AuthDataManager { ); // TODO dc 20250106: Save refreshed token in secure storage } + _cachedTokenResponse = tokenResponse; // Update in-memory cache return tokenResponse; } on Object { // debugPrint('AuthDataManager => _getTokenResponse() failed: $e'); diff --git a/lib/src/solid/utils/init_helper.dart b/lib/src/solid/utils/init_helper.dart index 4e03b605..ef44c8ec 100644 --- a/lib/src/solid/utils/init_helper.dart +++ b/lib/src/solid/utils/init_helper.dart @@ -183,18 +183,47 @@ Future initPod( } // Check (and generate) the directory URLs. + // Only regenerate when the caller did not provide any list (null). + // An empty list means "no directories to create" and should be respected. - if (dirUrls == null || dirUrls.isEmpty) { + if (dirUrls == null) { final defaultDirs = await generateDefaultFolders(); - dirUrls = [for (final d in defaultDirs) await getDirUrl(d)]; + // Resolve all directory URLs in parallel — each call only reads the + // in-memory webId (or at most one secure-storage read on first access). + dirUrls = await Future.wait(defaultDirs.map(getDirUrl)); } - // Require the creation of the encryption directory and - // the encKeyFile and indKeyFile in it. + // Determine whether the encryption directory needs to be created. + // + // The encryption directory is essential for key management. It may or may + // not already be in [dirUrls] depending on whether [initialStructureTest] + // detected it as missing. We check the server to decide: + // - If it already exists on the server → skip creation, just set the key. + // - If it does NOT exist → ensure it is in the creation list + // and perform full key initialisation. final encDirUrl = await getDirUrl(await getEncDirPath()); - if (!dirUrls.contains(encDirUrl)) { - throw Exception('Can not initialise POD without creating $encDirUrl'); + var needsEncSetup = dirUrls.contains(encDirUrl); + + if (!needsEncSetup) { + // Encryption directory is not in the creation list. + // Check whether it actually exists on the server. + + final status = await checkResourceStatus(encDirUrl, isFile: false); + if (status == ResourceStatus.notExist) { + // The directory is missing on the server AND was not in the creation + // list (e.g. because initialStructureTest returned forbidden/unknown, + // or because the caller only passed partial dirUrls). Add it so that + // it gets created below together with the other directories. + + dirUrls.add(encDirUrl); + needsEncSetup = true; + debugPrint( + 'initPod: encryption directory missing on server, ' + 'added to creation list', + ); + } + // else: directory exists on the server → needsEncSetup stays false. } // Create the required directories. @@ -208,62 +237,92 @@ Future initPod( } // Check (and generate) the file URLs. + // Only regenerate when the caller did not provide any list (null). + // An empty list means "no files to create" and should be respected. - if (fileUrls == null || fileUrls.isEmpty) { + if (fileUrls == null) { final defaultFiles = await generateDefaultFiles(); - fileUrls = []; + // Resolve all file URLs in parallel — each call reads the cached webId. + final urlFutures = >[]; for (final entry in defaultFiles.entries) { - final d = entry.key; + final d = entry.key as String; for (final f in entry.value as List) { - fileUrls.add([d, f].join('/')); + urlFutures.add(getFileUrl([d, f as String].join('/'))); } } + fileUrls = await Future.wait(urlFutures); } - // Create the encKeyFile, indKeyFile and pubKeyFile - // and remove them from the fileUrls list. - - await KeyManager.initPodKeys(securityKey); - fileUrls.remove(await getFileUrl(await getEncKeyPath())); - fileUrls.remove(await getFileUrl(await getIndKeyPath())); - fileUrls.remove(await getFileUrl(await getPubKeyPath())); - - for (final f in fileUrls) { - final fileName = f.split('/').last; - late String fileContent; - late bool aclFlag; - - if (f.split('.').last == 'acl') { - final items = f.split('.'); - final resourceUrl = items.getRange(0, items.length - 1).join('.'); - late Set publicAccess; - var isFile = true; - switch (fileName) { - case '$pubKeyFile.acl': - publicAccess = {AccessMode.read}; - case '$permLogFile.acl': - publicAccess = {AccessMode.append}; - default: - assert(fileName == '.acl'); - publicAccess = {AccessMode.read, AccessMode.write}; - isFile = false; - } + // Handle encryption key setup. - fileContent = await genAclTurtle( - resourceUrl, - isFile: isFile, - publicAccess: publicAccess, - ); + if (needsEncSetup) { + // First-time setup: create encryption key files from scratch. - aclFlag = true; - } else { - assert(fileName == permLogFile); - fileContent = genPermLogTTLStr(f); - aclFlag = false; - } + await KeyManager.initPodKeys(securityKey); + } else { + // Encryption already initialised: just verify and set the security key + // so that encrypted operations work in this session without overwriting + // existing keys. - await createResource(f, content: fileContent, replaceIfExist: aclFlag); + await KeyManager.setSecurityKey(securityKey); } + // Remove encryption key file URLs from the list (already handled above). + // Resolve all three key paths in parallel, then remove them from the list. + + final encKeyUrls = await Future.wait([ + getEncKeyPath().then(getFileUrl), + getIndKeyPath().then(getFileUrl), + getPubKeyPath().then(getFileUrl), + ]); + for (final url in encKeyUrls) { + fileUrls.remove(url); + } + + // Create each remaining file concurrently. + // Each file targets a distinct URL (PUT or a unique POST Slug), so + // parallel execution is safe with respect to server-side state. + + await Future.wait( + fileUrls.map((f) async { + final fileName = f.split('/').last; + late String fileContent; + late bool aclFlag; + + if (f.split('.').last == 'acl') { + final items = f.split('.'); + final resourceUrl = items.getRange(0, items.length - 1).join('.'); + late Set publicAccess; + var isFile = true; + switch (fileName) { + case '$pubKeyFile.acl': + publicAccess = {AccessMode.read}; + case '$permLogFile.acl': + publicAccess = {AccessMode.append}; + default: + assert(fileName == '.acl'); + publicAccess = {AccessMode.read, AccessMode.write}; + isFile = false; + } + + // genAclTurtle is async; run it inside the Future so all ACL content + // generation also happens concurrently. + fileContent = await genAclTurtle( + resourceUrl, + isFile: isFile, + publicAccess: publicAccess, + ); + + aclFlag = true; + } else { + assert(fileName == permLogFile); + fileContent = genPermLogTTLStr(f); + aclFlag = false; + } + + await createResource(f, content: fileContent, replaceIfExist: aclFlag); + }), + ); + await markPodStructureInitialised(); }