/* * Copyright (c) 2024-2026 Fabian Freund. * * This file is part of WebLibre * (see https://weblibre.eu). * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/features/bangs/data/models/bang_data.dart'; import 'package:weblibre/features/bangs/data/providers.dart'; import 'package:weblibre/utils/lru_cache.dart'; import 'package:weblibre/utils/uri_parser.dart' as uri_parser; part 'reverse_match.g.dart'; const String _placeholder = '{{{s}}}'; /// A URL-safe sentinel inserted in place of the bang's `{{{s}}}` placeholder /// during template parsing. Picked so it survives URI parsing without /// re-encoding and is extremely unlikely to appear in real templates. const String _sentinel = 'wlbangrevxq7p2zsentinel'; /// Where the placeholder sits inside a template. enum _Slot { mainQuery, mainPath, fragmentQuery, fragmentPath } /// Path + query pieces of a URL (or fragment) reduced to the parts we care /// about for reverse matching. class _Components { final List pathSegments; final Map queryParams; const _Components({required this.pathSegments, required this.queryParams}); } /// Parsed form of a bang URL template suitable for reverse matching. /// /// The placeholder can sit in one of four slots: main URL query/path or /// fragment query/path. Everything else in the template becomes a required /// constant during matching. Hash-router and #key=value fragments are both /// supported via [_componentsFromFragment]. class BangUrlPattern { final String host; // Main-URL constraints (always enforced). final List mainPathSegments; final Map mainQueryParams; // Fragment constraints (only when the template uses a fragment). final List? fragmentPathSegments; final Map? fragmentQueryParams; // Placeholder location + capture descriptors. final _Slot _slot; final String prefix; final String suffix; final String? paramName; // for *Query slots final int? pathIndex; // for *Path slots const BangUrlPattern._({ required this.host, required this.mainPathSegments, required this.mainQueryParams, required this.fragmentPathSegments, required this.fragmentQueryParams, required _Slot slot, required this.prefix, required this.suffix, this.paramName, this.pathIndex, }) : _slot = slot; /// Specificity score used to break ties between bangs sharing a host: /// more constants matched = more specific template. int get constraintCount => mainPathSegments.length + mainQueryParams.length + (fragmentPathSegments?.length ?? 0) + (fragmentQueryParams?.length ?? 0); /// Extracts the user query from [input] if it matches this pattern. Returns /// null otherwise. String? match(Uri input) { if (uri_parser.normalizeHost(input.host) != host) return null; final mainInput = _componentsFromUri(input); if (mainInput == null) return null; if (!_pathSegmentsMatchAround( mainPathSegments, mainInput.pathSegments, _slot == _Slot.mainPath ? pathIndex : null, )) { return null; } if (!_requiredQueryParamsMatch(mainQueryParams, mainInput.queryParams)) { return null; } _Components? fragmentInput; if (fragmentPathSegments != null || fragmentQueryParams != null) { fragmentInput = _componentsFromFragment(input.fragment); if (fragmentInput == null) return null; if (!_pathSegmentsMatchAround( fragmentPathSegments ?? const [], fragmentInput.pathSegments, _slot == _Slot.fragmentPath ? pathIndex : null, )) { return null; } if (!_requiredQueryParamsMatch( fragmentQueryParams ?? const {}, fragmentInput.queryParams, )) { return null; } } switch (_slot) { case _Slot.mainQuery: return _extractFromQuery(mainInput.queryParams); case _Slot.mainPath: return _extractFromPath(mainInput.pathSegments); case _Slot.fragmentQuery: return _extractFromQuery(fragmentInput!.queryParams); case _Slot.fragmentPath: return _extractFromPath(fragmentInput!.pathSegments); } } String? _extractFromQuery(Map params) { final value = params[paramName]; if (value == null) return null; if (!value.startsWith(prefix)) return null; if (!value.endsWith(suffix)) return null; final captured = value.substring( prefix.length, value.length - suffix.length, ); return captured.isEmpty ? null : captured; } String? _extractFromPath(List segments) { if (pathIndex == null || pathIndex! >= segments.length) return null; final segment = segments[pathIndex!]; if (!segment.startsWith(prefix)) return null; if (!segment.endsWith(suffix)) return null; final captured = segment.substring( prefix.length, segment.length - suffix.length, ); return captured.isEmpty ? null : captured; } static BangUrlPattern? parse(String urlTemplate) { final placeholderCount = _placeholder.allMatches(urlTemplate).length; if (placeholderCount != 1) { // Multi-placeholder templates are out of scope. return null; } final substituted = urlTemplate.replaceAll(_placeholder, _sentinel); final Uri parsed; try { parsed = Uri.parse(substituted); } on FormatException { return null; } if (!parsed.hasScheme || parsed.host.isEmpty) return null; if (parsed.host.contains(_sentinel)) return null; // Normalize so a template host and a redirected results host that differ // only by a generic subdomain (www./m.) still match. final host = uri_parser.normalizeHost(parsed.host); final fragmentSlot = parsed.fragment.contains(_sentinel); final templateMain = _componentsFromUri(parsed); if (templateMain == null) return null; if (fragmentSlot) { final fragmentComponents = _componentsFromFragment(parsed.fragment); if (fragmentComponents == null) return null; return _withPlaceholderInScope( host: host, mainPathSegments: templateMain.pathSegments, mainQueryParams: templateMain.queryParams, scopeComponents: fragmentComponents, queryslot: _Slot.fragmentQuery, pathSlot: _Slot.fragmentPath, isFragment: true, ); } // Placeholder lives in the main URL. return _withPlaceholderInScope( host: host, mainPathSegments: templateMain.pathSegments, mainQueryParams: templateMain.queryParams, scopeComponents: templateMain, queryslot: _Slot.mainQuery, pathSlot: _Slot.mainPath, isFragment: false, ); } /// Locates the sentinel inside [scopeComponents] (which may be the main URL /// components or the fragment's components) and assembles a [BangUrlPattern] /// with all required constants extracted. static BangUrlPattern? _withPlaceholderInScope({ required String host, required List mainPathSegments, required Map mainQueryParams, required _Components scopeComponents, required _Slot queryslot, required _Slot pathSlot, required bool isFragment, }) { // Try query slot first. String? sentinelParam; for (final entry in scopeComponents.queryParams.entries) { if (entry.value.contains(_sentinel)) { if (sentinelParam != null) return null; // ambiguous sentinelParam = entry.key; } } if (sentinelParam != null) { final placeholderValue = scopeComponents.queryParams[sentinelParam]!; final sentinelStart = placeholderValue.indexOf(_sentinel); final prefix = placeholderValue.substring(0, sentinelStart); final suffix = placeholderValue.substring( sentinelStart + _sentinel.length, ); // The other params in the same scope become required constants. final required = { for (final e in scopeComponents.queryParams.entries) if (e.key != sentinelParam) e.key: e.value, }; // We already required exactly one sentinel occurrence in [parse], so // if it lives in this scope's queries no path segment can also carry it. // The guard remains as defensive insurance. if (scopeComponents.pathSegments.any((s) => s.contains(_sentinel))) { return null; } return BangUrlPattern._( host: host, mainPathSegments: mainPathSegments, mainQueryParams: isFragment ? mainQueryParams : required, fragmentPathSegments: isFragment ? scopeComponents.pathSegments : null, fragmentQueryParams: isFragment ? required : null, slot: queryslot, prefix: prefix, suffix: suffix, paramName: sentinelParam, ); } // Otherwise the placeholder must be inside a single path segment. final segments = scopeComponents.pathSegments; final pathIndex = segments.indexWhere((s) => s.contains(_sentinel)); if (pathIndex < 0) return null; final segment = segments[pathIndex]; final sentinelStart = segment.indexOf(_sentinel); final prefix = segment.substring(0, sentinelStart); final suffix = segment.substring(sentinelStart + _sentinel.length); final pathSegments = List.from(segments); final required = scopeComponents.queryParams; if (required.values.any((v) => v.contains(_sentinel))) return null; return BangUrlPattern._( host: host, mainPathSegments: isFragment ? mainPathSegments : pathSegments, mainQueryParams: isFragment ? mainQueryParams : required, fragmentPathSegments: isFragment ? pathSegments : null, fragmentQueryParams: isFragment ? required : null, slot: pathSlot, prefix: prefix, suffix: suffix, pathIndex: pathIndex, ); } } /// Compares path segments. When [placeholderIndex] is non-null, the segment /// at that index is the placeholder and is skipped here (the caller verifies /// it against the stored prefix/suffix). bool _pathSegmentsMatchAround( List required, List actual, int? placeholderIndex, ) { if (actual.length != required.length) return false; for (var i = 0; i < required.length; i++) { if (i == placeholderIndex) continue; if (actual[i] != required[i]) return false; } return true; } bool _requiredQueryParamsMatch( Map required, Map actual, ) { if (required.isEmpty) return true; for (final entry in required.entries) { if (actual[entry.key] != entry.value) return false; } return true; } /// Reduces a [Uri] to the constraint set we care about. Bails on /// duplicate-keyed query params (multi-map semantics aren't worth the /// complexity for our use case). _Components? _componentsFromUri(Uri uri) { final segments = uri.pathSegments .where((s) => s.isNotEmpty) .toList(growable: false); final all = uri.queryParametersAll; final params = {}; for (final entry in all.entries) { if (entry.value.length != 1) return null; params[entry.key] = entry.value.first; } return _Components(pathSegments: segments, queryParams: params); } /// Parses a fragment string into pseudo-URI components. /// /// Two common shapes are handled: /// - hash-router style: starts with `/` (e.g. `/s/search/foo`) /// - hash-query style: contains `=` without leading `/` (e.g. `s=foo&t=bar`) /// Plain fragments without `/` or `=` are treated as a single path segment. _Components? _componentsFromFragment(String fragment) { if (fragment.isEmpty) return null; final String synthetic; if (fragment.startsWith('/')) { synthetic = 'http://x$fragment'; } else if (fragment.contains('=')) { synthetic = 'http://x/?$fragment'; } else { synthetic = 'http://x/$fragment'; } try { return _componentsFromUri(Uri.parse(synthetic)); } on FormatException { return null; } } /// Cached parsed patterns. `null` entries are cached too, so unsupported /// templates aren't re-parsed. final LRUCache _patternCache = LRUCache(256); BangUrlPattern? _patternFor(String urlTemplate) { if (_patternCache.contains(urlTemplate)) { return _patternCache.get(urlTemplate); } final parsed = BangUrlPattern.parse(urlTemplate); _patternCache.set(urlTemplate, parsed); return parsed; } /// Reverse-match result: the bang whose template best matches [url] together /// with the extracted user query. class ReverseBangMatch { final BangData bang; final String query; const ReverseBangMatch({required this.bang, required this.query}); } @Riverpod(keepAlive: true) class ReverseBangMatcher extends _$ReverseBangMatcher { @override void build() {} /// Looks up a bang whose template produces [url] and extracts the query. /// Returns null when no candidate matches. Future match(Uri url) async { final host = url.host; if (host.isEmpty) return null; final scheme = url.scheme.toLowerCase(); if (scheme != 'http' && scheme != 'https') return null; final candidates = await ref .read(bangDatabaseProvider) .bangDao .getBangDataByTemplateHost(host) .get(); if (candidates.isEmpty) return null; ReverseBangMatch? best; int bestScore = -1; int bestFrequency = -1; for (final bang in candidates) { final pattern = _patternFor(bang.urlTemplate); if (pattern == null) continue; final captured = pattern.match(url); if (captured == null) continue; // Don't auto-select when the captured text is itself a URL — usually // means we matched a redirector/shortener and the user wouldn't expect // the address bar to "edit" that. if (uri_parser.tryParseUrl(captured)?.hasScheme == true) continue; final score = pattern.constraintCount; final frequency = bang.frequency; if (score > bestScore || (score == bestScore && frequency > bestFrequency)) { best = ReverseBangMatch(bang: bang, query: captured); bestScore = score; bestFrequency = frequency; } } return best; } }