diff --git a/core/lib/core.dart b/core/lib/core.dart index d354c2689..8fa332280 100644 --- a/core/lib/core.dart +++ b/core/lib/core.dart @@ -59,6 +59,7 @@ export 'utils/application_manager.dart'; export 'utils/preview_eml_file_utils.dart'; export 'utils/logger/log_tracking.dart'; export 'utils/html/html_utils.dart'; +export 'utils/web_link_generator.dart'; // Views export 'presentation/views/text/slogan_builder.dart'; diff --git a/core/lib/utils/web_link_generator.dart b/core/lib/utils/web_link_generator.dart new file mode 100644 index 000000000..e01275a58 --- /dev/null +++ b/core/lib/utils/web_link_generator.dart @@ -0,0 +1,86 @@ +import 'package:core/core.dart'; + +/// Utility class for generating web links in flat subdomain format. +class WebLinkGenerator { + const WebLinkGenerator(); + + /// Ensures that a path starts with `/`. + String ensureFirstSlash(String? path) { + if (path == null || path.isEmpty) return '/'; + return path.startsWith('/') ? path : '/$path'; + } + + /// Validates if a given FQDN looks valid (has at least 2 parts and non-empty). + bool isValidFqdn(String fqdn) { + if (fqdn.trim().isEmpty || !fqdn.contains('.')) return false; + final parts = fqdn.split('.').where((p) => p.trim().isNotEmpty).toList(); + return parts.length >= 2; + } + + /// Generates a web link based on a workplace FQDN and app slug. + /// + /// Throws [ArgumentError] when input is invalid. + String generateWebLink({ + required String workplaceFqdn, + required String slug, + String? pathname, + String? hash, + List>? searchParams, + }) { + if (!isValidFqdn(workplaceFqdn)) { + throw ArgumentError('Invalid workplace FQDN: $workplaceFqdn'); + } + + final hostParts = workplaceFqdn + .split('.') + .where((p) => p.trim().isNotEmpty) + .toList(); + + hostParts[0] = '${hostParts[0]}-$slug'; + final newHost = hostParts.join('.'); + + final safePath = ensureFirstSlash(pathname); + final safeHash = (hash == null || hash.isEmpty) ? null : ensureFirstSlash(hash); + + final queryParams = {}; + for (final param in searchParams ?? const []) { + if (param.length == 2) queryParams[param[0]] = param[1]; + } + + final uri = Uri( + scheme: 'https', + host: newHost, + path: safePath, + queryParameters: queryParams.isEmpty ? null : queryParams, + fragment: safeHash, + ); + + return uri.toString(); + } + + /// A safe version of [generateWebLink] that **never throws**. + /// + /// Returns `''` (empty string) when an error occurs. + String safeGenerateWebLink({ + required String workplaceFqdn, + required String slug, + String? pathname, + String? hash, + List>? searchParams, + }) { + try { + if (slug.trim().isEmpty) return ''; + + return generateWebLink( + workplaceFqdn: workplaceFqdn, + slug: slug, + pathname: pathname, + hash: hash, + searchParams: searchParams, + ); + } catch (e) { + logError('[WebLinkGenerator] Error: $e'); + return ''; + } + } +} diff --git a/core/test/utils/web_link_generator_test.dart b/core/test/utils/web_link_generator_test.dart new file mode 100644 index 000000000..7884d162c --- /dev/null +++ b/core/test/utils/web_link_generator_test.dart @@ -0,0 +1,136 @@ +import 'package:core/utils/web_link_generator.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('WebLinkGenerator (with workplaceFqdn)', () { + const generator = WebLinkGenerator(); + + test('should generate correct link for flat subdomain', () { + final result = generator.generateWebLink( + workplaceFqdn: 'alice.example.app', + searchParams: [ + ['sharecode', 'sharingIsCaring'], + ['username', 'alice'], + ], + pathname: 'public', + hash: '/n/4', + slug: 'notes', + ); + + expect( + result, + 'https://alice-notes.example.app/public?sharecode=sharingIsCaring&username=alice#/n/4', + ); + }); + + test('should prepend slash to path and hash if missing', () { + final result = generator.generateWebLink( + workplaceFqdn: 'bob.example.tools', + pathname: 'files', + hash: 'folder/123', + slug: 'drive', + ); + + expect(result, 'https://bob-drive.example.tools/files#/folder/123'); + }); + + test('should work with no search params', () { + final result = generator.generateWebLink( + workplaceFqdn: 'charlie.domain.com', + slug: 'settings', + ); + + expect(result, 'https://charlie-settings.domain.com/'); + }); + + test('should throw when workplaceFqdn is empty', () { + expect( + () => generator.generateWebLink( + workplaceFqdn: '', + slug: 'notes', + ), + throwsA(isA()), + ); + }); + + test('should throw when workplaceFqdn only contains TLD', () { + expect( + () => generator.generateWebLink( + workplaceFqdn: 'com', + slug: 'notes', + ), + throwsA(isA()), + ); + }); + + test('should throw when workplaceFqdn has no dot', () { + expect( + () => generator.generateWebLink( + workplaceFqdn: 'localhost', + slug: 'notes', + ), + throwsA(isA()), + ); + }); + + test('should handle malformed FQDN gracefully (multiple dots)', () { + final result = generator.generateWebLink( + workplaceFqdn: '....example..app', + slug: 'notes', + ); + + expect(result.startsWith('https://'), isTrue); + expect(result.contains('-notes'), isTrue); + }); + + test('should ignore malformed searchParams', () { + final result = generator.generateWebLink( + workplaceFqdn: 'dave.example.app', + slug: 'drive', + searchParams: [ + ['username'], + ['key', 'value'], + ], + ); + + expect(result, 'https://dave-drive.example.app/?key=value'); + }); + + test('should not add fragment when hash is empty string', () { + final result = generator.generateWebLink( + workplaceFqdn: 'eve.example.app', + slug: 'settings', + hash: '', + ); + + expect(result, 'https://eve-settings.example.app/'); + }); + + test('safeGenerateWebLink should return empty string on invalid FQDN', () { + final result = generator.safeGenerateWebLink( + workplaceFqdn: '', + slug: 'notes', + ); + + expect(result, ''); + }); + + test('safeGenerateWebLink should return empty string on missing slug', () { + final result = generator.safeGenerateWebLink( + workplaceFqdn: 'alice.example.app', + slug: '', + ); + + expect(result, ''); + }); + + test('safeGenerateWebLink should still return valid URL when input ok', () { + final result = generator.safeGenerateWebLink( + workplaceFqdn: 'bob.example.app', + slug: 'drive', + ); + + expect(result, 'https://bob-drive.example.app/'); + }); + }); +}