container wildcard assignments

This commit is contained in:
Fabian Freund
2026-04-18 04:05:50 +02:00
parent f9bcd076f2
commit d059db384c
11 changed files with 223 additions and 97 deletions
@@ -171,10 +171,6 @@ class ContainerDao extends DatabaseAccessor<TabDatabase>
); );
} }
SingleSelectable<bool> isSiteAssignedToContainer(Uri uri) {
return db.definitionsDrift.isSiteAssignedToContainer(uri: uri.origin);
}
SingleSelectable<bool> areSitesAvailable( SingleSelectable<bool> areSitesAvailable(
Iterable<Uri> origins, Iterable<Uri> origins,
String ignoredContainerId, String ignoredContainerId,
@@ -185,10 +181,6 @@ class ContainerDao extends DatabaseAccessor<TabDatabase>
); );
} }
Selectable<String> siteAssignedContainerId(Uri uri) {
return db.definitionsDrift.siteAssignedContainerId(uri: uri.origin);
}
Selectable<SiteAssignment> allAssignedSites() { Selectable<SiteAssignment> allAssignedSites() {
return db.definitionsDrift.allAssignedSites(); return db.definitionsDrift.allAssignedSites();
} }
@@ -297,14 +297,6 @@ nextTabByOrderKey(:tab_id AS TEXT, :container_id AS TEXT OR NULL, :skip_containe
FROM ranked_tabs FROM ranked_tabs
WHERE id = :tab_id; 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: areSitesAvailable:
SELECT NOT EXISTS ( SELECT NOT EXISTS (
SELECT 1 SELECT 1
@@ -317,15 +309,6 @@ areSitesAvailable:
container.id IS NOT :ignore_container_id container.id IS NOT :ignore_container_id
) AS existing; ) AS existing;
siteAssignedContainerId:
SELECT id
FROM container
WHERE EXISTS (
SELECT 1
FROM json_each(container.metadata, '$.assignedSites')
WHERE value = :uri
);
allAssignedSites WITH SiteAssignment: allAssignedSites WITH SiteAssignment:
SELECT SELECT
container.id, container.id,
@@ -2557,14 +2557,6 @@ class DefinitionsDrift extends i9.ModularAccessor {
).map((i0.QueryRow row) => row.readNullable<String>('next_tab_id')); ).map((i0.QueryRow row) => row.readNullable<String>('next_tab_id'));
} }
i0.Selectable<bool> 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<String>(uri)],
readsFrom: {container},
).map((i0.QueryRow row) => row.read<bool>('existing'));
}
i0.Selectable<bool> areSitesAvailable({ i0.Selectable<bool> areSitesAvailable({
required String uriList, required String uriList,
required String ignoreContainerId, required String ignoreContainerId,
@@ -2579,14 +2571,6 @@ class DefinitionsDrift extends i9.ModularAccessor {
).map((i0.QueryRow row) => row.read<bool>('existing')); ).map((i0.QueryRow row) => row.read<bool>('existing'));
} }
i0.Selectable<String> 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<String>(uri)],
readsFrom: {container},
).map((i0.QueryRow row) => row.read<String>('id'));
}
i0.Selectable<i11.SiteAssignment> allAssignedSites() { i0.Selectable<i11.SiteAssignment> allAssignedSites() {
return customSelect( 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', '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',
@@ -34,3 +34,27 @@ class SiteAssignment with FastEquatable {
@override @override
List<Object?> get hashParameters => [id, contextualIdentity, assignedSite]; List<Object?> 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;
}
@@ -201,5 +201,9 @@ Stream<bool> watchIsCurrentSiteAssignedToContainer(Ref ref) {
); );
final db = ref.watch(tabDatabaseProvider); 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),
);
});
} }
@@ -1093,4 +1093,4 @@ final class WatchIsCurrentSiteAssignedToContainerProvider
} }
String _$watchIsCurrentSiteAssignedToContainerHash() => String _$watchIsCurrentSiteAssignedToContainerHash() =>
r'c2a82ca9df526c7a703da52302182e497c91e824'; r'b4e39fecacc84f53adfd1d6368a70a84db181661';
@@ -24,6 +24,7 @@ import 'package:nullability/nullability.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/uuid.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/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/data/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/utils/color_palette.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); return ContainerData(id: uuid.v7(), color: initialColor);
} }
Future<bool> isSiteAssignedToContainer(Uri uri) { Future<bool> isSiteAssignedToContainer(Uri uri) async {
return ref return (await siteAssignedContainerId(uri)) != null;
.read(tabDatabaseProvider)
.containerDao
.isSiteAssignedToContainer(uri)
.getSingle();
} }
Future<bool> areSitesAvailable( Future<bool> areSitesAvailable(
@@ -177,13 +174,21 @@ class ContainerRepository extends _$ContainerRepository {
.getSingle(); .getSingle();
} }
Future<String?> siteAssignedContainerId(Uri uri) { Future<String?> siteAssignedContainerId(Uri uri) async {
return ref final all = await ref
.read(tabDatabaseProvider) .read(tabDatabaseProvider)
.containerDao .containerDao
.siteAssignedContainerId(uri) .allAssignedSites()
.get() .get();
.then((value) => value.firstOrNull);
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<List<String>> getContainersToClearOnExit() async { Future<List<String>> getContainersToClearOnExit() async {
@@ -42,7 +42,7 @@ final class ContainerRepositoryProvider
} }
String _$containerRepositoryHash() => String _$containerRepositoryHash() =>
r'57ee339087a845e54dc79224e25fa0da19568a68'; r'55c3e897f4f42d01dab43114ef00075536b74cf7';
abstract class _$ContainerRepository extends $Notifier<void> { abstract class _$ContainerRepository extends $Notifier<void> {
void build(); void build();
@@ -22,11 +22,54 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.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/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart';
import 'package:weblibre/utils/form_validators.dart'; import 'package:weblibre/utils/form_validators.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper; 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 { class ContainerSitesScreen extends HookConsumerWidget {
final Set<Uri> initialSites; final Set<Uri> initialSites;
@@ -66,7 +109,9 @@ class ContainerSitesScreen extends HookConsumerWidget {
child: TextFormField( child: TextFormField(
decoration: InputDecoration( decoration: InputDecoration(
label: const Text('Add Site'), 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, floatingLabelBehavior: FloatingLabelBehavior.always,
suffix: TextButton( suffix: TextButton(
onPressed: () { onPressed: () {
@@ -80,55 +125,42 @@ class ContainerSitesScreen extends HookConsumerWidget {
controller: textController, controller: textController,
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
validator: (value) { validator: (value) {
final uriValid = validateUrl( if (value.isEmpty) {
value, return 'URL must be provided';
onlyHttpProtocol: true,
eagerParsing: true,
);
if (uriValid != null) {
return uriValid;
} }
final parsedUrl = parseValidatedUrl( final entry = _parseSiteAssignmentInput(value);
value, if (entry == null) {
eagerParsing: true,
onlyHttpProtocol: true,
);
if (parsedUrl == null) {
return 'Invalid URL'; return 'Invalid URL';
} }
final origin = Uri.parse(parsedUrl.origin); if (sites.value.contains(entry)) {
if (sites.value.contains(origin)) {
return 'This site has been already assigned'; return 'This site has been already assigned';
} }
return null; return null;
}, },
onSaved: (newValue) async { onSaved: (newValue) async {
final parsedUrl = parseValidatedUrl( final entry = _parseSiteAssignmentInput(newValue);
newValue, if (entry == null) {
eagerParsing: true,
onlyHttpProtocol: true,
);
if (parsedUrl == null) {
return; return;
} }
final origin = Uri.parse(parsedUrl.origin); // Exact-origin duplicate detection — wildcard overlaps
// with existing entries are the user's intent and are
final isAssigned = await ref // not flagged here.
.read(containerRepositoryProvider.notifier) final isAssigned =
.isSiteAssignedToContainer(origin); !isWildcardSite(entry) &&
await ref
.read(containerRepositoryProvider.notifier)
.isSiteAssignedToContainer(entry);
if (!isAssigned) { if (!isAssigned) {
sites.value = {...sites.value, origin}; sites.value = {...sites.value, entry};
} else { } else {
final assignedContainerId = await ref final assignedContainerId = await ref
.read(containerRepositoryProvider.notifier) .read(containerRepositoryProvider.notifier)
.siteAssignedContainerId(origin); .siteAssignedContainerId(entry);
final assignedContainer = await assignedContainerId final assignedContainer = await assignedContainerId
.mapNotNull( .mapNotNull(
(id) => ref (id) => ref
@@ -140,8 +172,8 @@ class ContainerSitesScreen extends HookConsumerWidget {
ui_helper.showErrorMessage( ui_helper.showErrorMessage(
context, context,
(assignedContainer?.name.isNotEmpty ?? false) (assignedContainer?.name.isNotEmpty ?? false)
? '$origin has already been assigned to container "${assignedContainer?.name}"' ? '$entry has already been assigned to container "${assignedContainer?.name}"'
: '$origin has already been assigned to another container', : '$entry has already been assigned to another container',
); );
} }
} }
@@ -75,22 +75,76 @@ function fillInDefaults(proxy: Partial<ProxyDao>): ProxyDao {
return proxy as ProxyDao return proxy as ProxyDao
} }
interface WildcardAssignment {
protocol: string
hostSuffix: string
contextId: string
}
export class Store { export class Store {
private proxies: ProxyDao[] = [] private proxies: ProxyDao[] = []
private relations: { [key: string]: string[] } = {} private relations: { [key: string]: string[] } = {}
private siteAssignments: Map<string, string> = new Map<string, string>() private siteAssignments: Map<string, string> = new Map<string, string>()
private wildcardAssignments: WildcardAssignment[] = []
setSiteAssignments(sites: Map<string, unknown>): void { setSiteAssignments(sites: Map<string, unknown>): void {
this.siteAssignments = new Map( const exact = new Map<string, string>()
Array.from(sites, ([key, value]) => { const wildcard: WildcardAssignment[] = []
return [URL.parse(key)!.origin, value as string]
}) // `*` 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 { 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 { 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 === undefined) return false;
if (assignedContextId === contextId) return true; if (assignedContextId === contextId) return true;
@@ -119,8 +173,8 @@ export class Store {
// empty relations mean no proxy, and different non-proxied contexts // empty relations mean no proxy, and different non-proxied contexts
// should not be considered equivalent. // should not be considered equivalent.
if (assignedRelation.length > 0 && if (assignedRelation.length > 0 &&
assignedRelation.length === currentRelation.length && assignedRelation.length === currentRelation.length &&
assignedRelation.every((id, i) => id === currentRelation[i])) { assignedRelation.every((id, i) => id === currentRelation[i])) {
return true; return true;
} }
@@ -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 () { describe('isSiteOriginInSameContext', function () {
it('should allow proxy-equivalent isolated context', async () => { it('should allow proxy-equivalent isolated context', async () => {
store.setSiteAssignments(new Map([['https://example.com/page', 'container_ctx']])) store.setSiteAssignments(new Map([['https://example.com/page', 'container_ctx']]))