TF-4145 Add generateWebLink and safeGenerateWebLink with FQDN validation
This commit is contained in:
@@ -59,6 +59,7 @@ export 'utils/application_manager.dart';
|
|||||||
export 'utils/preview_eml_file_utils.dart';
|
export 'utils/preview_eml_file_utils.dart';
|
||||||
export 'utils/logger/log_tracking.dart';
|
export 'utils/logger/log_tracking.dart';
|
||||||
export 'utils/html/html_utils.dart';
|
export 'utils/html/html_utils.dart';
|
||||||
|
export 'utils/web_link_generator.dart';
|
||||||
|
|
||||||
// Views
|
// Views
|
||||||
export 'presentation/views/text/slogan_builder.dart';
|
export 'presentation/views/text/slogan_builder.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<List<String>>? 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 = <String, String>{};
|
||||||
|
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<List<String>>? 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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ArgumentError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw when workplaceFqdn only contains TLD', () {
|
||||||
|
expect(
|
||||||
|
() => generator.generateWebLink(
|
||||||
|
workplaceFqdn: 'com',
|
||||||
|
slug: 'notes',
|
||||||
|
),
|
||||||
|
throwsA(isA<ArgumentError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw when workplaceFqdn has no dot', () {
|
||||||
|
expect(
|
||||||
|
() => generator.generateWebLink(
|
||||||
|
workplaceFqdn: 'localhost',
|
||||||
|
slug: 'notes',
|
||||||
|
),
|
||||||
|
throwsA(isA<ArgumentError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user