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(
Iterable<Uri> origins,
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() {
return db.definitionsDrift.allAssignedSites();
}
@@ -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,
@@ -2557,14 +2557,6 @@ class DefinitionsDrift extends i9.ModularAccessor {
).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({
required String uriList,
required String ignoreContainerId,
@@ -2579,14 +2571,6 @@ class DefinitionsDrift extends i9.ModularAccessor {
).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() {
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',
@@ -34,3 +34,27 @@ class SiteAssignment with FastEquatable {
@override
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);
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() =>
r'c2a82ca9df526c7a703da52302182e497c91e824';
r'b4e39fecacc84f53adfd1d6368a70a84db181661';
@@ -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<bool> isSiteAssignedToContainer(Uri uri) {
return ref
.read(tabDatabaseProvider)
.containerDao
.isSiteAssignedToContainer(uri)
.getSingle();
Future<bool> isSiteAssignedToContainer(Uri uri) async {
return (await siteAssignedContainerId(uri)) != null;
}
Future<bool> areSitesAvailable(
@@ -177,13 +174,21 @@ class ContainerRepository extends _$ContainerRepository {
.getSingle();
}
Future<String?> siteAssignedContainerId(Uri uri) {
return ref
Future<String?> 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<List<String>> getContainersToClearOnExit() async {
@@ -42,7 +42,7 @@ final class ContainerRepositoryProvider
}
String _$containerRepositoryHash() =>
r'57ee339087a845e54dc79224e25fa0da19568a68';
r'55c3e897f4f42d01dab43114ef00075536b74cf7';
abstract class _$ContainerRepository extends $Notifier<void> {
void build();
@@ -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<Uri> 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',
);
}
}