diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/container.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/container.dart index 00aafbce..fc35fd38 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/container.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/container.dart @@ -171,10 +171,6 @@ class ContainerDao extends DatabaseAccessor ); } - SingleSelectable isSiteAssignedToContainer(Uri uri) { - return db.definitionsDrift.isSiteAssignedToContainer(uri: uri.origin); - } - SingleSelectable areSitesAvailable( Iterable origins, String ignoredContainerId, @@ -185,10 +181,6 @@ class ContainerDao extends DatabaseAccessor ); } - Selectable siteAssignedContainerId(Uri uri) { - return db.definitionsDrift.siteAssignedContainerId(uri: uri.origin); - } - Selectable allAssignedSites() { return db.definitionsDrift.allAssignedSites(); } diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift index 768b0336..02f9a6af 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift @@ -297,14 +297,6 @@ nextTabByOrderKey(:tab_id AS TEXT, :container_id AS TEXT OR NULL, :skip_containe FROM ranked_tabs WHERE id = :tab_id; -isSiteAssignedToContainer: - SELECT EXISTS ( - SELECT 1 - FROM container - CROSS JOIN json_each(container.metadata, '$.assignedSites') - WHERE json_each.value = :uri - ) AS existing; - areSitesAvailable: SELECT NOT EXISTS ( SELECT 1 @@ -317,15 +309,6 @@ areSitesAvailable: container.id IS NOT :ignore_container_id ) AS existing; -siteAssignedContainerId: - SELECT id - FROM container - WHERE EXISTS ( - SELECT 1 - FROM json_each(container.metadata, '$.assignedSites') - WHERE value = :uri - ); - allAssignedSites WITH SiteAssignment: SELECT container.id, diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart index 867e1ea1..c8bd2763 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart @@ -2557,14 +2557,6 @@ class DefinitionsDrift extends i9.ModularAccessor { ).map((i0.QueryRow row) => row.readNullable('next_tab_id')); } - i0.Selectable isSiteAssignedToContainer({String? uri}) { - return customSelect( - 'SELECT EXISTS (SELECT 1 AS _c0 FROM container CROSS JOIN json_each(container.metadata, \'\$.assignedSites\')WHERE json_each.value = ?1) AS existing', - variables: [i0.Variable(uri)], - readsFrom: {container}, - ).map((i0.QueryRow row) => row.read('existing')); - } - i0.Selectable areSitesAvailable({ required String uriList, required String ignoreContainerId, @@ -2579,14 +2571,6 @@ class DefinitionsDrift extends i9.ModularAccessor { ).map((i0.QueryRow row) => row.read('existing')); } - i0.Selectable siteAssignedContainerId({String? uri}) { - return customSelect( - 'SELECT id FROM container WHERE EXISTS (SELECT 1 AS _c0 FROM json_each(container.metadata, \'\$.assignedSites\')WHERE value = ?1)', - variables: [i0.Variable(uri)], - readsFrom: {container}, - ).map((i0.QueryRow row) => row.read('id')); - } - i0.Selectable allAssignedSites() { return customSelect( 'SELECT container.id, COALESCE(container.metadata ->> \'\$.contextualIdentity\', \'general\') AS contextualIdentity, value AS assigned_site FROM container CROSS JOIN json_each(container.metadata, \'\$.assignedSites\')WHERE value IS NOT NULL', diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/site_assignment.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/site_assignment.dart index 1f8ae8f5..cbf66551 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/site_assignment.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/site_assignment.dart @@ -34,3 +34,27 @@ class SiteAssignment with FastEquatable { @override List get hashParameters => [id, contextualIdentity, assignedSite]; } + +const String _wildcardPrefix = '*.'; + +/// A host starting with `*.` (e.g. `*.example.com`) matches the bare apex +/// and any subdomain of it. +bool isWildcardSite(Uri assignment) => + assignment.host.startsWith(_wildcardPrefix); + +/// Tests whether [assignment] (a stored site-assignment entry) matches the +/// given [request] URL. Exact entries compare origins. Wildcard entries +/// (host starting with `*.`) match the apex and any subdomain with the +/// same scheme, ignoring port. +bool siteAssignmentMatches(Uri assignment, Uri request) { + if (assignment.scheme != request.scheme) return false; + + if (isWildcardSite(assignment)) { + final suffix = assignment.host.substring(_wildcardPrefix.length); + if (suffix.isEmpty) return false; + final host = request.host; + return host == suffix || host.endsWith('.$suffix'); + } + + return assignment.origin == request.origin; +} diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.dart index cc979e2b..a932baf5 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.dart @@ -201,5 +201,9 @@ Stream watchIsCurrentSiteAssignedToContainer(Ref ref) { ); final db = ref.watch(tabDatabaseProvider); - return db.containerDao.isSiteAssignedToContainer(currentUri).watchSingle(); + return db.containerDao.allAssignedSites().watch().map((assignments) { + return assignments.any( + (a) => siteAssignmentMatches(a.assignedSite, currentUri), + ); + }); } diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.g.dart index de6f40fa..7e283390 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.g.dart @@ -1093,4 +1093,4 @@ final class WatchIsCurrentSiteAssignedToContainerProvider } String _$watchIsCurrentSiteAssignedToContainerHash() => - r'c2a82ca9df526c7a703da52302182e497c91e824'; + r'b4e39fecacc84f53adfd1d6368a70a84db181661'; diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/container.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/container.dart index f884fce5..b9d0fa84 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/container.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/container.dart @@ -24,6 +24,7 @@ import 'package:nullability/nullability.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/core/uuid.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/tabs/utils/color_palette.dart'; @@ -158,12 +159,8 @@ class ContainerRepository extends _$ContainerRepository { return ContainerData(id: uuid.v7(), color: initialColor); } - Future isSiteAssignedToContainer(Uri uri) { - return ref - .read(tabDatabaseProvider) - .containerDao - .isSiteAssignedToContainer(uri) - .getSingle(); + Future isSiteAssignedToContainer(Uri uri) async { + return (await siteAssignedContainerId(uri)) != null; } Future areSitesAvailable( @@ -177,13 +174,21 @@ class ContainerRepository extends _$ContainerRepository { .getSingle(); } - Future siteAssignedContainerId(Uri uri) { - return ref + Future siteAssignedContainerId(Uri uri) async { + final all = await ref .read(tabDatabaseProvider) .containerDao - .siteAssignedContainerId(uri) - .get() - .then((value) => value.firstOrNull); + .allAssignedSites() + .get(); + + String? wildcardMatch; + for (final a in all) { + if (siteAssignmentMatches(a.assignedSite, uri)) { + if (!isWildcardSite(a.assignedSite)) return a.id; + wildcardMatch ??= a.id; + } + } + return wildcardMatch; } Future> getContainersToClearOnExit() async { diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart index 39e3d00c..7bd9a643 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart @@ -42,7 +42,7 @@ final class ContainerRepositoryProvider } String _$containerRepositoryHash() => - r'57ee339087a845e54dc79224e25fa0da19568a68'; + r'55c3e897f4f42d01dab43114ef00075536b74cf7'; abstract class _$ContainerRepository extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_sites.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_sites.dart index e56bb577..c3ce6b51 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_sites.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_sites.dart @@ -22,11 +22,54 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; import 'package:weblibre/utils/form_validators.dart'; import 'package:weblibre/utils/ui_helper.dart' as ui_helper; +final _wildcardHostRegex = RegExp( + r'^\*\.([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,63}$', +); + +/// Parses a user-entered site-assignment value. Accepts: +/// - `example.com`, `https://example.com/path` → exact origin entry +/// - `*.example.com`, `https://*.example.com` → wildcard entry for all +/// subdomains (and the apex) of `example.com` +Uri? _parseSiteAssignmentInput(String? input) { + if (input == null) return null; + final trimmed = input.trim(); + if (trimmed.isEmpty) return null; + + // Strip scheme for wildcard detection. + var rest = trimmed; + var scheme = 'https'; + final schemeMatch = RegExp(r'^(https?):\/\/', caseSensitive: false) + .firstMatch(trimmed); + if (schemeMatch != null) { + scheme = schemeMatch.group(1)!.toLowerCase(); + rest = trimmed.substring(schemeMatch.end); + } + + // Extract host portion (up to first path/query/fragment/port separator). + final hostEnd = rest.indexOf(RegExp(r'[\/?#:]')); + final host = (hostEnd == -1 ? rest : rest.substring(0, hostEnd)) + .toLowerCase(); + + if (host.startsWith('*.')) { + if (!_wildcardHostRegex.hasMatch(host)) return null; + return Uri(scheme: scheme, host: host); + } + + final parsed = parseValidatedUrl( + trimmed, + eagerParsing: true, + onlyHttpProtocol: true, + ); + if (parsed == null) return null; + return Uri.parse(parsed.origin); +} + class ContainerSitesScreen extends HookConsumerWidget { final Set initialSites; @@ -66,7 +109,9 @@ class ContainerSitesScreen extends HookConsumerWidget { child: TextFormField( decoration: InputDecoration( label: const Text('Add Site'), - hintText: 'example.com', + hintText: 'example.com or *.example.com', + helperText: + 'Use *.example.com to match all subdomains', floatingLabelBehavior: FloatingLabelBehavior.always, suffix: TextButton( onPressed: () { @@ -80,55 +125,42 @@ class ContainerSitesScreen extends HookConsumerWidget { controller: textController, keyboardType: TextInputType.url, validator: (value) { - final uriValid = validateUrl( - value, - onlyHttpProtocol: true, - eagerParsing: true, - ); - - if (uriValid != null) { - return uriValid; + if (value.isEmpty) { + return 'URL must be provided'; } - final parsedUrl = parseValidatedUrl( - value, - eagerParsing: true, - onlyHttpProtocol: true, - ); - if (parsedUrl == null) { + final entry = _parseSiteAssignmentInput(value); + if (entry == null) { return 'Invalid URL'; } - final origin = Uri.parse(parsedUrl.origin); - - if (sites.value.contains(origin)) { + if (sites.value.contains(entry)) { return 'This site has been already assigned'; } return null; }, onSaved: (newValue) async { - final parsedUrl = parseValidatedUrl( - newValue, - eagerParsing: true, - onlyHttpProtocol: true, - ); - if (parsedUrl == null) { + final entry = _parseSiteAssignmentInput(newValue); + if (entry == null) { return; } - final origin = Uri.parse(parsedUrl.origin); - - final isAssigned = await ref - .read(containerRepositoryProvider.notifier) - .isSiteAssignedToContainer(origin); + // Exact-origin duplicate detection — wildcard overlaps + // with existing entries are the user's intent and are + // not flagged here. + final isAssigned = + !isWildcardSite(entry) && + await ref + .read(containerRepositoryProvider.notifier) + .isSiteAssignedToContainer(entry); if (!isAssigned) { - sites.value = {...sites.value, origin}; + sites.value = {...sites.value, entry}; } else { final assignedContainerId = await ref .read(containerRepositoryProvider.notifier) - .siteAssignedContainerId(origin); + .siteAssignedContainerId(entry); final assignedContainer = await assignedContainerId .mapNotNull( (id) => ref @@ -140,8 +172,8 @@ class ContainerSitesScreen extends HookConsumerWidget { ui_helper.showErrorMessage( context, (assignedContainer?.name.isNotEmpty ?? false) - ? '$origin has already been assigned to container "${assignedContainer?.name}"' - : '$origin has already been assigned to another container', + ? '$entry has already been assigned to container "${assignedContainer?.name}"' + : '$entry has already been assigned to another container', ); } } diff --git a/packages/flutter_mozilla_components/javascript/container_proxy/src/store/Store.ts b/packages/flutter_mozilla_components/javascript/container_proxy/src/store/Store.ts index c0ab6718..277ed0ee 100644 --- a/packages/flutter_mozilla_components/javascript/container_proxy/src/store/Store.ts +++ b/packages/flutter_mozilla_components/javascript/container_proxy/src/store/Store.ts @@ -75,22 +75,76 @@ function fillInDefaults(proxy: Partial): ProxyDao { return proxy as ProxyDao } +interface WildcardAssignment { + protocol: string + hostSuffix: string + contextId: string +} + export class Store { private proxies: ProxyDao[] = [] private relations: { [key: string]: string[] } = {} private siteAssignments: Map = new Map() + private wildcardAssignments: WildcardAssignment[] = [] setSiteAssignments(sites: Map): void { - this.siteAssignments = new Map( - Array.from(sites, ([key, value]) => { - return [URL.parse(key)!.origin, value as string] - }) - ); + const exact = new Map() + const wildcard: WildcardAssignment[] = [] + + // `*` is a forbidden host code point in WHATWG URL, so wildcard entries + // must be detected by string match before any URL parser touches them. + const wildcardRe = /^(https?):\/\/\*\.([^/?#]+)$/i + + for (const [key, value] of sites) { + const contextId = value as string + const wildcardMatch = wildcardRe.exec(key) + + if (wildcardMatch !== null) { + const hostSuffix = wildcardMatch[2].toLowerCase() + if (hostSuffix.length === 0) continue + wildcard.push({ + protocol: wildcardMatch[1].toLowerCase() + ':', + hostSuffix, + contextId, + }) + continue + } + + const parsed = URL.parse(key) + if (parsed === null) continue + exact.set(parsed.origin, contextId) + } + + this.siteAssignments = exact + this.wildcardAssignments = wildcard + } + + private matchWildcard(uri: URL): string | undefined { + // Longest suffix wins, so e.g. *.sub.example.com beats *.example.com. + let bestMatch: WildcardAssignment | undefined + for (const entry of this.wildcardAssignments) { + if (entry.protocol !== uri.protocol) continue + const host = uri.hostname + if (host !== entry.hostSuffix && !host.endsWith('.' + entry.hostSuffix)) { + continue + } + if ( + bestMatch === undefined || + entry.hostSuffix.length > bestMatch.hostSuffix.length + ) { + bestMatch = entry + } + } + return bestMatch?.contextId + } + + private lookupAssignment(uri: URL): string | undefined { + return this.siteAssignments.get(uri.origin) ?? this.matchWildcard(uri) } isSiteOriginAssigned(uri: URL): boolean { - return this.siteAssignments.has(uri.origin) + return this.lookupAssignment(uri) !== undefined } /** @@ -105,7 +159,7 @@ export class Store { } isSiteOriginInSameContext(uri: URL, contextId: string): boolean { - const assignedContextId = this.siteAssignments.get(uri.origin); + const assignedContextId = this.lookupAssignment(uri); if (assignedContextId === undefined) return false; if (assignedContextId === contextId) return true; @@ -119,8 +173,8 @@ export class Store { // empty relations mean no proxy, and different non-proxied contexts // should not be considered equivalent. if (assignedRelation.length > 0 && - assignedRelation.length === currentRelation.length && - assignedRelation.every((id, i) => id === currentRelation[i])) { + assignedRelation.length === currentRelation.length && + assignedRelation.every((id, i) => id === currentRelation[i])) { return true; } diff --git a/packages/flutter_mozilla_components/javascript/container_proxy/test/unit/Store.test.ts b/packages/flutter_mozilla_components/javascript/container_proxy/test/unit/Store.test.ts index 8bf395e6..991909ac 100644 --- a/packages/flutter_mozilla_components/javascript/container_proxy/test/unit/Store.test.ts +++ b/packages/flutter_mozilla_components/javascript/container_proxy/test/unit/Store.test.ts @@ -165,6 +165,54 @@ describe('Store', () => { }) }) + describe('wildcard site assignments', function () { + it('matches subdomains of a wildcard entry', () => { + store.setSiteAssignments(new Map([['https://*.example.com', 'ctx1']])) + + expect(store.isSiteOriginAssigned(new URL('https://foo.example.com/path'))).to.be.true + expect(store.isSiteOriginAssigned(new URL('https://a.b.example.com/'))).to.be.true + }) + + it('matches the apex of a wildcard entry', () => { + store.setSiteAssignments(new Map([['https://*.example.com', 'ctx1']])) + + expect(store.isSiteOriginAssigned(new URL('https://example.com/path'))).to.be.true + }) + + it('does not match unrelated domains', () => { + store.setSiteAssignments(new Map([['https://*.example.com', 'ctx1']])) + + expect(store.isSiteOriginAssigned(new URL('https://notexample.com/'))).to.be.false + expect(store.isSiteOriginAssigned(new URL('https://example.com.evil.test/'))).to.be.false + }) + + it('respects scheme when matching wildcards', () => { + store.setSiteAssignments(new Map([['https://*.example.com', 'ctx1']])) + + expect(store.isSiteOriginAssigned(new URL('http://foo.example.com/'))).to.be.false + }) + + it('prefers exact match over wildcard', () => { + store.setSiteAssignments(new Map([ + ['https://*.example.com', 'wild_ctx'], + ['https://foo.example.com', 'exact_ctx'], + ])) + + expect(store.isSiteOriginInSameContext(new URL('https://foo.example.com/'), 'exact_ctx')).to.be.true + expect(store.isSiteOriginInSameContext(new URL('https://bar.example.com/'), 'wild_ctx')).to.be.true + }) + + it('prefers longer suffix when multiple wildcards match', () => { + store.setSiteAssignments(new Map([ + ['https://*.example.com', 'broad_ctx'], + ['https://*.sub.example.com', 'narrow_ctx'], + ])) + + expect(store.isSiteOriginInSameContext(new URL('https://x.sub.example.com/'), 'narrow_ctx')).to.be.true + expect(store.isSiteOriginInSameContext(new URL('https://x.other.example.com/'), 'broad_ctx')).to.be.true + }) + }) + describe('isSiteOriginInSameContext', function () { it('should allow proxy-equivalent isolated context', async () => { store.setSiteAssignments(new Map([['https://example.com/page', 'container_ctx']]))