container wildcard assignments
This commit is contained in:
@@ -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,
|
||||
|
||||
-16
@@ -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';
|
||||
|
||||
+16
-11
@@ -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 {
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ final class ContainerRepositoryProvider
|
||||
}
|
||||
|
||||
String _$containerRepositoryHash() =>
|
||||
r'57ee339087a845e54dc79224e25fa0da19568a68';
|
||||
r'55c3e897f4f42d01dab43114ef00075536b74cf7';
|
||||
|
||||
abstract class _$ContainerRepository extends $Notifier<void> {
|
||||
void build();
|
||||
|
||||
+65
-33
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user