Add Supa account and search changes

This commit is contained in:
Fabian Freund
2026-05-22 18:10:22 +02:00
parent 3a19865b2e
commit 51289f1266
374 changed files with 54061 additions and 5013 deletions
@@ -0,0 +1,62 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
class SearchCreditsStatus with FastEquatable {
final int availableCredits;
final int monthlyAllowance;
final DateTime? lastResetAt;
final DateTime? lastIssuanceAt;
final DateTime? currentPeriodEnd;
SearchCreditsStatus({
required this.availableCredits,
this.monthlyAllowance = 0,
this.lastResetAt,
this.lastIssuanceAt,
this.currentPeriodEnd,
});
factory SearchCreditsStatus.fromJson(Map<String, dynamic> json) {
DateTime? parse(Object? v) => (v is String) ? DateTime.parse(v) : null;
return SearchCreditsStatus(
availableCredits: (json['available_credits'] as num?)?.toInt() ?? 0,
monthlyAllowance: (json['monthly_allowance'] as num?)?.toInt() ?? 0,
lastResetAt: parse(json['last_reset_at']),
lastIssuanceAt: parse(json['last_issuance_at']),
currentPeriodEnd: parse(json['current_period_end']),
);
}
// Not `const` because `FastEquatable` carries a cached-hash field that
// disallows const construction. Value-equality from the mixin means
// `SearchCreditsStatus(availableCredits: 0) == SearchCreditsStatus.empty`
// still holds, which is what Riverpod's `select` dedupe cares about.
static final empty = SearchCreditsStatus(availableCredits: 0);
@override
List<Object?> get hashParameters => [
availableCredits,
monthlyAllowance,
lastResetAt,
lastIssuanceAt,
currentPeriodEnd,
];
}
@@ -0,0 +1,80 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:search_backend/search_backend.dart';
part 'web_search_settings.g.dart';
@CopyWith()
@JsonSerializable(includeIfNull: true, constructor: 'withDefaults')
class WebSearchSettings with FastEquatable {
final bool routeThroughTor;
final SearchMode searchMode;
/// ISO 639-1 language code (e.g. `en`, `de`). `null` means "use backend
/// default" (currently English).
final String? language;
/// ISO 3166-1 alpha-2 region/country code (e.g. `US`, `DE`). `null`
/// means "no region preference" — engines won't apply region boosts.
final String? region;
/// Safe-search level. `null` defers to the server default (moderate).
final SafeSearch? safeSearch;
/// Freshness filter. `null` means "any time".
final TimeRange? timeRange;
WebSearchSettings({
required this.routeThroughTor,
required this.searchMode,
required this.language,
required this.region,
required this.safeSearch,
required this.timeRange,
});
WebSearchSettings.withDefaults({
bool? routeThroughTor,
SearchMode? searchMode,
this.language,
this.region,
this.safeSearch,
this.timeRange,
}) : routeThroughTor = routeThroughTor ?? false,
searchMode = searchMode ?? SearchMode.general;
factory WebSearchSettings.fromJson(Map<String, dynamic> json) =>
_$WebSearchSettingsFromJson(json);
Map<String, dynamic> toJson() => _$WebSearchSettingsToJson(this);
@override
List<Object?> get hashParameters => [
routeThroughTor,
searchMode,
language,
region,
safeSearch,
timeRange,
];
}
@@ -0,0 +1,165 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'web_search_settings.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$WebSearchSettingsCWProxy {
WebSearchSettings routeThroughTor(bool routeThroughTor);
WebSearchSettings searchMode(SearchMode searchMode);
WebSearchSettings language(String? language);
WebSearchSettings region(String? region);
WebSearchSettings safeSearch(SafeSearch? safeSearch);
WebSearchSettings timeRange(TimeRange? timeRange);
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `WebSearchSettings(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// WebSearchSettings(...).copyWith(id: 12, name: "My name")
/// ```
WebSearchSettings call({
bool routeThroughTor,
SearchMode searchMode,
String? language,
String? region,
SafeSearch? safeSearch,
TimeRange? timeRange,
});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfWebSearchSettings.copyWith(...)` or call `instanceOfWebSearchSettings.copyWith.fieldName(value)` for a single field.
class _$WebSearchSettingsCWProxyImpl implements _$WebSearchSettingsCWProxy {
const _$WebSearchSettingsCWProxyImpl(this._value);
final WebSearchSettings _value;
@override
WebSearchSettings routeThroughTor(bool routeThroughTor) =>
call(routeThroughTor: routeThroughTor);
@override
WebSearchSettings searchMode(SearchMode searchMode) =>
call(searchMode: searchMode);
@override
WebSearchSettings language(String? language) => call(language: language);
@override
WebSearchSettings region(String? region) => call(region: region);
@override
WebSearchSettings safeSearch(SafeSearch? safeSearch) =>
call(safeSearch: safeSearch);
@override
WebSearchSettings timeRange(TimeRange? timeRange) =>
call(timeRange: timeRange);
@override
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `WebSearchSettings(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// WebSearchSettings(...).copyWith(id: 12, name: "My name")
/// ```
WebSearchSettings call({
Object? routeThroughTor = const $CopyWithPlaceholder(),
Object? searchMode = const $CopyWithPlaceholder(),
Object? language = const $CopyWithPlaceholder(),
Object? region = const $CopyWithPlaceholder(),
Object? safeSearch = const $CopyWithPlaceholder(),
Object? timeRange = const $CopyWithPlaceholder(),
}) {
return WebSearchSettings(
routeThroughTor:
routeThroughTor == const $CopyWithPlaceholder() ||
routeThroughTor == null
? _value.routeThroughTor
// ignore: cast_nullable_to_non_nullable
: routeThroughTor as bool,
searchMode:
searchMode == const $CopyWithPlaceholder() || searchMode == null
? _value.searchMode
// ignore: cast_nullable_to_non_nullable
: searchMode as SearchMode,
language: language == const $CopyWithPlaceholder()
? _value.language
// ignore: cast_nullable_to_non_nullable
: language as String?,
region: region == const $CopyWithPlaceholder()
? _value.region
// ignore: cast_nullable_to_non_nullable
: region as String?,
safeSearch: safeSearch == const $CopyWithPlaceholder()
? _value.safeSearch
// ignore: cast_nullable_to_non_nullable
: safeSearch as SafeSearch?,
timeRange: timeRange == const $CopyWithPlaceholder()
? _value.timeRange
// ignore: cast_nullable_to_non_nullable
: timeRange as TimeRange?,
);
}
}
extension $WebSearchSettingsCopyWith on WebSearchSettings {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfWebSearchSettings.copyWith(...)` or `instanceOfWebSearchSettings.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$WebSearchSettingsCWProxy get copyWith =>
_$WebSearchSettingsCWProxyImpl(this);
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
WebSearchSettings _$WebSearchSettingsFromJson(Map<String, dynamic> json) =>
WebSearchSettings.withDefaults(
routeThroughTor: json['routeThroughTor'] as bool?,
searchMode: $enumDecodeNullable(_$SearchModeEnumMap, json['searchMode']),
language: json['language'] as String?,
region: json['region'] as String?,
safeSearch: $enumDecodeNullable(_$SafeSearchEnumMap, json['safeSearch']),
timeRange: $enumDecodeNullable(_$TimeRangeEnumMap, json['timeRange']),
);
Map<String, dynamic> _$WebSearchSettingsToJson(WebSearchSettings instance) =>
<String, dynamic>{
'routeThroughTor': instance.routeThroughTor,
'searchMode': _$SearchModeEnumMap[instance.searchMode]!,
'language': instance.language,
'region': instance.region,
'safeSearch': _$SafeSearchEnumMap[instance.safeSearch],
'timeRange': _$TimeRangeEnumMap[instance.timeRange],
};
const _$SearchModeEnumMap = {
SearchMode.general: 'general',
SearchMode.independentWeb: 'independentWeb',
SearchMode.smallWeb: 'smallWeb',
};
const _$SafeSearchEnumMap = {
SafeSearch.none: 'none',
SafeSearch.moderate: 'moderate',
SafeSearch.strict: 'strict',
};
const _$TimeRangeEnumMap = {
TimeRange.day: 'day',
TimeRange.week: 'week',
TimeRange.month: 'month',
TimeRange.year: 'year',
};
@@ -0,0 +1,41 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
abstract final class SearchBackendConfig {
/// Clearnet origin used when the user has not opted to route search through
/// Tor. Must be a normal HTTPS URL (or HTTP for dev).
static const searchBackendOrigin = String.fromEnvironment(
'SEARCH_BACKEND_ORIGIN',
defaultValue: 'https://search.weblibre.eu',
);
/// Origin used when the user opts to route search through Tor. Should be
/// the WebLibre search service's onion address so the Tor circuit
/// terminates inside the Tor network instead of exiting back to the
/// clearnet. Falls back to the clearnet origin when no onion address is
/// configured at build time.
static const searchBackendOriginTor = String.fromEnvironment(
'SEARCH_BACKEND_ORIGIN_TOR',
defaultValue:
'http://eyipgwt32zaejr2xwblaswp2ur4qikapofunbqus5dklvf7jxkncirad.onion',
);
static Uri get originUri => Uri.parse(searchBackendOrigin);
static Uri get torOriginUri => Uri.parse(searchBackendOriginTor);
}
@@ -0,0 +1,77 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'dart:typed_data';
import 'package:search_client/search_client.dart';
import 'package:weblibre/features/user/data/database/daos/search_tokens.dart';
/// Drift-backed [TokenStash]. The optional [issuerKeyVersion] is only used
/// by [add], and only the issuance controller is supposed to call that.
/// Read-only consumers (the search session: reserve / commit / release /
/// count / clear) can leave it null; calling [add] on a stash constructed
/// without a key version is a programmer error and throws.
class DriftTokenStash implements TokenStash {
final SearchTokensDao dao;
final String? issuerKeyVersion;
DriftTokenStash(this.dao, {this.issuerKeyVersion});
@override
Future<void> add(List<Uint8List> tokens) {
final version = issuerKeyVersion;
if (version == null) {
throw StateError(
'DriftTokenStash.add called without an issuerKeyVersion — '
'construct a stash with the version returned by '
'IssuanceClient.fetchPublicKey() before issuing tokens.',
);
}
return dao.addTokens(tokens, issuerKeyVersion: version);
}
@override
Future<ReservedStashToken?> reserveOne() async {
final reserved = await dao.reserveOne();
if (reserved == null) return null;
return ReservedStashToken(id: reserved.id, token: reserved.token);
}
@override
Future<void> commitReserved(int id) => dao.commitReserved(id);
@override
Future<void> releaseReserved(int id) => dao.releaseReserved(id);
@override
Future<Uint8List?> takeOne() async {
final reserved = await reserveOne();
if (reserved == null) return null;
await commitReserved(reserved.id);
return reserved.token;
}
@override
Future<int> count() => dao.count();
@override
Future<void> clear() async {
await dao.clear();
}
}