Add Supa account and search changes
This commit is contained in:
@@ -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 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// A PKCE code-verifier / code-challenge pair tied to a single sign-in
|
||||
/// attempt. The verifier stays on the device; the challenge is sent to the
|
||||
/// account web app and later redeemed alongside the verifier to prove the
|
||||
/// app instance that started the flow is the one finishing it.
|
||||
///
|
||||
/// **Encoding deviates from RFC 7636.** The reference spec mandates
|
||||
/// base64url for both verifier and `S256` challenge; this implementation
|
||||
/// uses lowercase hex on both sides:
|
||||
/// - verifier: 32 random bytes encoded as 64 hex characters (within the
|
||||
/// 43–128 range allowed by RFC 7636 §4.1).
|
||||
/// - challenge: hex digest of `SHA-256(UTF-8(verifier))`.
|
||||
///
|
||||
/// Both sides of the flow are owned (this client and the `handoff-redeem`
|
||||
/// Supabase function), so the wire format is internally consistent. The
|
||||
/// deviation matters only if the redeem endpoint is ever replaced with a
|
||||
/// standards-compliant OAuth server — in which case both `_generateCodeVerifier`
|
||||
/// and `_challengeFor` must switch to base64url to interoperate.
|
||||
class PkceCodes {
|
||||
final String verifier;
|
||||
final String challenge;
|
||||
|
||||
const PkceCodes({required this.verifier, required this.challenge});
|
||||
|
||||
factory PkceCodes.generate() {
|
||||
final verifier = _generateCodeVerifier();
|
||||
return PkceCodes(verifier: verifier, challenge: _challengeFor(verifier));
|
||||
}
|
||||
|
||||
static String _generateCodeVerifier() {
|
||||
final random = Random.secure();
|
||||
final bytes = List<int>.generate(32, (_) => random.nextInt(256));
|
||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
static String _challengeFor(String verifier) {
|
||||
return sha256.convert(utf8.encode(verifier)).toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
/// Parses Firefox-compatible `user.js` text into a map of pref name to value.
|
||||
///
|
||||
/// Accepts the Firefox `user.js` subset used by WebLibre:
|
||||
/// - blank lines and whitespace
|
||||
/// - `//`, `#`, and `/* ... */` comments
|
||||
/// - `user_pref("name", value);`
|
||||
/// - string, boolean, and integer values
|
||||
///
|
||||
/// Duplicate pref names use last-write-wins semantics.
|
||||
class UserJsParseResult {
|
||||
final Map<String, Object> prefs;
|
||||
final int? schemaVersion;
|
||||
final String? exportedAt;
|
||||
|
||||
UserJsParseResult({required this.prefs, this.schemaVersion, this.exportedAt});
|
||||
}
|
||||
|
||||
UserJsParseResult parseUserJs(String text) {
|
||||
final parser = _UserJsParser(text);
|
||||
return parser.parse();
|
||||
}
|
||||
|
||||
class _UserJsParser {
|
||||
final String _text;
|
||||
int _index = 0;
|
||||
final Map<String, Object> _prefs = <String, Object>{};
|
||||
int? _schemaVersion;
|
||||
String? _exportedAt;
|
||||
|
||||
_UserJsParser(this._text);
|
||||
|
||||
UserJsParseResult parse() {
|
||||
while (true) {
|
||||
_skipTrivia();
|
||||
if (_isEof) {
|
||||
break;
|
||||
}
|
||||
|
||||
final identifier = _parseIdentifier();
|
||||
if (identifier != 'user_pref') {
|
||||
_skipToStatementEnd();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_consumeChar('(')) {
|
||||
_skipToStatementEnd();
|
||||
continue;
|
||||
}
|
||||
|
||||
final name = _parseStringLiteral();
|
||||
if (name == null) {
|
||||
_skipToStatementEnd();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_consumeChar(',')) {
|
||||
_skipToStatementEnd();
|
||||
continue;
|
||||
}
|
||||
|
||||
final value = _parseValue();
|
||||
if (value == null) {
|
||||
_skipToStatementEnd();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_consumeChar(')')) {
|
||||
_skipToStatementEnd();
|
||||
continue;
|
||||
}
|
||||
|
||||
_skipTrivia();
|
||||
if (!_consumeRawChar(';')) {
|
||||
_skipToStatementEnd();
|
||||
continue;
|
||||
}
|
||||
|
||||
_prefs[name] = value;
|
||||
}
|
||||
|
||||
return UserJsParseResult(
|
||||
prefs: _prefs,
|
||||
schemaVersion: _schemaVersion,
|
||||
exportedAt: _exportedAt,
|
||||
);
|
||||
}
|
||||
|
||||
bool get _isEof => _index >= _text.length;
|
||||
|
||||
String? _peek([int offset = 0]) {
|
||||
final position = _index + offset;
|
||||
if (position >= _text.length) {
|
||||
return null;
|
||||
}
|
||||
return _text[position];
|
||||
}
|
||||
|
||||
String? _advance() {
|
||||
if (_isEof) {
|
||||
return null;
|
||||
}
|
||||
return _text[_index++];
|
||||
}
|
||||
|
||||
bool _consumeRawChar(String char) {
|
||||
if (_peek() != char) {
|
||||
return false;
|
||||
}
|
||||
_index++;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _consumeChar(String char) {
|
||||
_skipTrivia();
|
||||
return _consumeRawChar(char);
|
||||
}
|
||||
|
||||
void _skipTrivia() {
|
||||
while (!_isEof) {
|
||||
final char = _peek();
|
||||
if (char == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isWhitespace(char)) {
|
||||
_advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char == '#') {
|
||||
_skipLineComment(isSlashComment: false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char == '/' && _peek(1) == '/') {
|
||||
_skipLineComment(isSlashComment: true);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char == '/' && _peek(1) == '*') {
|
||||
_skipBlockComment();
|
||||
continue;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void _skipLineComment({required bool isSlashComment}) {
|
||||
final prefixLength = isSlashComment ? 2 : 1;
|
||||
final start = _index + prefixLength;
|
||||
_index += prefixLength;
|
||||
|
||||
while (!_isEof) {
|
||||
final char = _peek();
|
||||
if (char == '\n' || char == '\r') {
|
||||
break;
|
||||
}
|
||||
_index++;
|
||||
}
|
||||
|
||||
if (isSlashComment) {
|
||||
_parseMetadataComment(_text.substring(start, _index).trim());
|
||||
}
|
||||
|
||||
if (_peek() == '\r') {
|
||||
_index++;
|
||||
if (_peek() == '\n') {
|
||||
_index++;
|
||||
}
|
||||
} else if (_peek() == '\n') {
|
||||
_index++;
|
||||
}
|
||||
}
|
||||
|
||||
void _skipBlockComment() {
|
||||
_index += 2;
|
||||
while (!_isEof) {
|
||||
if (_peek() == '*' && _peek(1) == '/') {
|
||||
_index += 2;
|
||||
return;
|
||||
}
|
||||
_index++;
|
||||
}
|
||||
}
|
||||
|
||||
void _parseMetadataComment(String comment) {
|
||||
if (comment.startsWith('schema_version=')) {
|
||||
_schemaVersion = int.tryParse(
|
||||
comment.substring('schema_version='.length),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (comment.startsWith('exported_at=')) {
|
||||
_exportedAt = comment.substring('exported_at='.length);
|
||||
}
|
||||
}
|
||||
|
||||
String? _parseIdentifier() {
|
||||
_skipTrivia();
|
||||
final start = _index;
|
||||
while (!_isEof) {
|
||||
final char = _peek();
|
||||
if (char == null || !_isIdentifierChar(char)) {
|
||||
break;
|
||||
}
|
||||
_index++;
|
||||
}
|
||||
if (_index == start) {
|
||||
return null;
|
||||
}
|
||||
return _text.substring(start, _index);
|
||||
}
|
||||
|
||||
Object? _parseValue() {
|
||||
_skipTrivia();
|
||||
final char = _peek();
|
||||
if (char == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (char == '"' || char == "'") {
|
||||
return _parseStringLiteral();
|
||||
}
|
||||
|
||||
if (char == 't' || char == 'f') {
|
||||
final identifier = _parseIdentifier();
|
||||
if (identifier == 'true') {
|
||||
return true;
|
||||
}
|
||||
if (identifier == 'false') {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return _parseIntLiteral();
|
||||
}
|
||||
|
||||
int? _parseIntLiteral() {
|
||||
_skipTrivia();
|
||||
final start = _index;
|
||||
|
||||
var sign = 1;
|
||||
if (_peek() == '+') {
|
||||
_index++;
|
||||
} else if (_peek() == '-') {
|
||||
sign = -1;
|
||||
_index++;
|
||||
}
|
||||
|
||||
// Firefox tokenizes +/- separately, then skips whitespace/comments before
|
||||
// reading the integer literal.
|
||||
_skipTrivia();
|
||||
|
||||
final digitStart = _index;
|
||||
while (!_isEof) {
|
||||
final char = _peek();
|
||||
if (char == null || !_isDigit(char)) {
|
||||
break;
|
||||
}
|
||||
_index++;
|
||||
}
|
||||
|
||||
if (_index == digitStart) {
|
||||
_index = start;
|
||||
return null;
|
||||
}
|
||||
|
||||
final trailing = _peek();
|
||||
if (trailing != null && _isIdentifierChar(trailing)) {
|
||||
_index = start;
|
||||
return null;
|
||||
}
|
||||
|
||||
final digits = _text.substring(digitStart, _index);
|
||||
final value = int.tryParse(digits);
|
||||
if (value == null) {
|
||||
_index = start;
|
||||
return null;
|
||||
}
|
||||
|
||||
final signedValue = sign * value;
|
||||
if (signedValue < -2147483648 || signedValue > 2147483647) {
|
||||
_index = start;
|
||||
return null;
|
||||
}
|
||||
|
||||
return signedValue;
|
||||
}
|
||||
|
||||
String? _parseStringLiteral() {
|
||||
_skipTrivia();
|
||||
final quote = _peek();
|
||||
if (quote != '"' && quote != "'") {
|
||||
return null;
|
||||
}
|
||||
|
||||
_index++;
|
||||
final buffer = StringBuffer();
|
||||
|
||||
while (!_isEof) {
|
||||
final char = _advance();
|
||||
if (char == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (char == quote) {
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
if (char != '\\') {
|
||||
buffer.write(char);
|
||||
continue;
|
||||
}
|
||||
|
||||
final escaped = _parseEscape();
|
||||
if (escaped == null) {
|
||||
return null;
|
||||
}
|
||||
buffer.write(escaped);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _parseEscape() {
|
||||
final char = _advance();
|
||||
switch (char) {
|
||||
case '"':
|
||||
return '"';
|
||||
case "'":
|
||||
return "'";
|
||||
case '\\':
|
||||
return '\\';
|
||||
case 'n':
|
||||
return '\n';
|
||||
case 'r':
|
||||
return '\r';
|
||||
case 'x':
|
||||
final value = _parseHexValue(length: 2);
|
||||
if (value == null || value == 0) {
|
||||
return null;
|
||||
}
|
||||
return String.fromCharCode(value);
|
||||
case 'u':
|
||||
final value = _parseUnicodeEscape();
|
||||
if (value == null || value == 0) {
|
||||
return null;
|
||||
}
|
||||
return String.fromCharCode(value);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
int? _parseUnicodeEscape() {
|
||||
final value = _parseHexValue(length: 4);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_isHighSurrogate(value)) {
|
||||
if (_advance() != '\\' || _advance() != 'u') {
|
||||
return null;
|
||||
}
|
||||
final lowValue = _parseHexValue(length: 4);
|
||||
if (lowValue == null || !_isLowSurrogate(lowValue)) {
|
||||
return null;
|
||||
}
|
||||
return 0x10000 + ((value - 0xD800) << 10) + (lowValue - 0xDC00);
|
||||
}
|
||||
|
||||
if (_isLowSurrogate(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
int? _parseHexValue({required int length}) {
|
||||
var value = 0;
|
||||
for (var i = 0; i < length; i++) {
|
||||
final char = _advance();
|
||||
final digit = char == null ? null : _hexDigitValue(char);
|
||||
if (digit == null) {
|
||||
return null;
|
||||
}
|
||||
value = (value << 4) + digit;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
int? _hexDigitValue(String char) {
|
||||
final code = char.codeUnitAt(0);
|
||||
if (code >= 0x30 && code <= 0x39) {
|
||||
return code - 0x30;
|
||||
}
|
||||
if (code >= 0x41 && code <= 0x46) {
|
||||
return code - 0x41 + 10;
|
||||
}
|
||||
if (code >= 0x61 && code <= 0x66) {
|
||||
return code - 0x61 + 10;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _skipToStatementEnd() {
|
||||
while (!_isEof) {
|
||||
final char = _advance();
|
||||
if (char == ';') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _isWhitespace(String char) {
|
||||
return char == ' ' ||
|
||||
char == '\t' ||
|
||||
char == '\n' ||
|
||||
char == '\r' ||
|
||||
char == '\v' ||
|
||||
char == '\f';
|
||||
}
|
||||
|
||||
bool _isIdentifierChar(String char) {
|
||||
final code = char.codeUnitAt(0);
|
||||
return (code >= 0x41 && code <= 0x5A) ||
|
||||
(code >= 0x61 && code <= 0x7A) ||
|
||||
(code >= 0x30 && code <= 0x39) ||
|
||||
code == 0x5F;
|
||||
}
|
||||
|
||||
bool _isDigit(String char) {
|
||||
final code = char.codeUnitAt(0);
|
||||
return code >= 0x30 && code <= 0x39;
|
||||
}
|
||||
|
||||
bool _isHighSurrogate(int value) => value >= 0xD800 && value <= 0xDBFF;
|
||||
|
||||
bool _isLowSurrogate(int value) => value >= 0xDC00 && value <= 0xDFFF;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
/// Unsyncable URL-style pref value prefixes (inspired by Firefox Sync).
|
||||
const unsyncablePrefPrefixes = ['moz-extension:', 'blob:', 'data:', 'file:'];
|
||||
|
||||
/// Returns true if a persisted Gecko pref value is syncable (supported scalar
|
||||
/// type and not an unsyncable URL-style string value).
|
||||
bool isSyncablePrefValue(Object value) {
|
||||
if (value is bool || value is int) return true;
|
||||
if (value is String) {
|
||||
return !unsyncablePrefPrefixes.any(value.startsWith);
|
||||
}
|
||||
// Unsupported type (e.g. double, list)
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Serializes persisted Gecko user prefs into canonical `user.js` text.
|
||||
///
|
||||
/// [userPrefs] is the map of user-set prefs parsed from the active Gecko
|
||||
/// profile's `prefs.js` file.
|
||||
///
|
||||
/// String-valued prefs starting with unsyncable URL prefixes are excluded.
|
||||
String serializeUserJs({
|
||||
required Map<String, Object> userPrefs,
|
||||
required int schemaVersion,
|
||||
String? exportedAt,
|
||||
}) {
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('// WebLibre Gecko prefs snapshot');
|
||||
buffer.writeln('// schema_version=$schemaVersion');
|
||||
buffer.writeln(
|
||||
'// exported_at=${exportedAt ?? DateTime.now().toUtc().toIso8601String()}',
|
||||
);
|
||||
|
||||
final syncable =
|
||||
userPrefs.entries.where((e) => isSyncablePrefValue(e.value)).toList()
|
||||
..sort((a, b) => a.key.compareTo(b.key));
|
||||
|
||||
for (final entry in syncable) {
|
||||
final literal = _toLiteral(entry.value);
|
||||
if (literal == null) continue;
|
||||
final escapedName = _escapeString(entry.key);
|
||||
buffer.writeln('user_pref("$escapedName", $literal);');
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String? _toLiteral(Object? value) {
|
||||
if (value is bool) return value.toString();
|
||||
if (value is int) return value.toString();
|
||||
if (value is String) return '"${_escapeString(value)}"';
|
||||
return null;
|
||||
}
|
||||
|
||||
String _escapeString(String value) {
|
||||
return value
|
||||
.replaceAll('\\', r'\\')
|
||||
.replaceAll('"', r'\"')
|
||||
.replaceAll('\n', r'\n')
|
||||
.replaceAll('\r', r'\r');
|
||||
}
|
||||
Reference in New Issue
Block a user