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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,22 +75,76 @@ function fillInDefaults(proxy: Partial<ProxyDao>): 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<string, string> = new Map<string, string>()
|
||||
private wildcardAssignments: WildcardAssignment[] = []
|
||||
|
||||
setSiteAssignments(sites: Map<string, unknown>): void {
|
||||
this.siteAssignments = new Map(
|
||||
Array.from(sites, ([key, value]) => {
|
||||
return [URL.parse(key)!.origin, value as string]
|
||||
})
|
||||
);
|
||||
const exact = new Map<string, string>()
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+48
@@ -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']]))
|
||||
|
||||
Reference in New Issue
Block a user