feat: add Gemini and Spartan protocol support
This commit is contained in:
parent
a4a49f41d7
commit
fa7aa5557c
48 changed files with 4992 additions and 161 deletions
|
|
@ -1,12 +1,21 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:actors/actors.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../mews/atom.dart';
|
||||
import '../../mews/document.dart';
|
||||
import '../../mews/gemtext.dart';
|
||||
import '../../mews/parser.dart';
|
||||
import '../../protocols/gemini.dart' as gemini;
|
||||
import '../../protocols/spartan.dart' as spartan;
|
||||
import '../../protocols/tofu.dart';
|
||||
|
||||
/// Commands accepted by the fetch actor. Network I/O and XML parsing both
|
||||
/// happen in the actor's isolate; results return by message only.
|
||||
/// Commands accepted by the fetch actor. Network I/O, XML parsing and the
|
||||
/// protocol transports all happen in the actor's isolate; results return by
|
||||
/// message only.
|
||||
sealed class FetchCommand {
|
||||
const FetchCommand();
|
||||
}
|
||||
|
|
@ -14,16 +23,38 @@ sealed class FetchCommand {
|
|||
/// Fetch [url], sending validators when the page is cached. A 304 parses
|
||||
/// [cachedSource] in place of a body, and a network failure falls back to it,
|
||||
/// so the reader stays online-transparent and works offline.
|
||||
///
|
||||
/// [pins] are the reader's trusted hosts by `host:port`, which the Gemini
|
||||
/// transport checks every certificate against. The validators mean nothing to
|
||||
/// Gemini and Spartan: a cached protocol page is only the offline fallback.
|
||||
///
|
||||
/// [clientCert] and [clientKey] are the PEM of the identity the reader bound
|
||||
/// to this host. Both are passed because the actor's isolate cannot read the
|
||||
/// database or the key files; the identity is worn only where the reader
|
||||
/// chose to wear it.
|
||||
class FetchPage extends FetchCommand {
|
||||
const FetchPage({required this.url, this.etag, this.lastModified, this.cachedSource});
|
||||
const FetchPage({
|
||||
required this.url,
|
||||
this.etag,
|
||||
this.lastModified,
|
||||
this.cachedSource,
|
||||
this.pins = const {},
|
||||
this.clientCert,
|
||||
this.clientKey,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final String? etag;
|
||||
final String? lastModified;
|
||||
final String? cachedSource;
|
||||
final Map<String, TofuPin> pins;
|
||||
final String? clientCert;
|
||||
final String? clientKey;
|
||||
}
|
||||
|
||||
/// Submit a form's fields to its action URL (spec 4.2).
|
||||
/// Submit a form's fields to its action URL (spec 4.2). An action on a
|
||||
/// protocol page carries the fields as a query string, the same way the
|
||||
/// protocol's own input requests do.
|
||||
class SubmitForm extends FetchCommand {
|
||||
const SubmitForm({required this.url, required this.method, required this.fields});
|
||||
|
||||
|
|
@ -49,6 +80,23 @@ class ParseSource extends FetchCommand {
|
|||
final String source;
|
||||
}
|
||||
|
||||
/// Fetches the raw bytes of [url] over its protocol, for a body that is not a
|
||||
/// page to read: an image on a capsule. The pins and the identity travel with
|
||||
/// the command the way they do for a page.
|
||||
class FetchBytes extends FetchCommand {
|
||||
const FetchBytes({
|
||||
required this.url,
|
||||
this.pins = const {},
|
||||
this.clientCert,
|
||||
this.clientKey,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final Map<String, TofuPin> pins;
|
||||
final String? clientCert;
|
||||
final String? clientKey;
|
||||
}
|
||||
|
||||
sealed class FetchResult {
|
||||
const FetchResult();
|
||||
}
|
||||
|
|
@ -68,17 +116,24 @@ class FetchPageOk extends FetchResult {
|
|||
this.etag,
|
||||
this.lastModified,
|
||||
this.notModified = false,
|
||||
this.pinUpdates = const [],
|
||||
});
|
||||
|
||||
final MewsPage page;
|
||||
|
||||
/// The markup the page was parsed from, for the cache.
|
||||
/// The markup the page was parsed from, for the cache. Empty when there is
|
||||
/// nothing text worth keeping, such as an image.
|
||||
final String source;
|
||||
final String? etag;
|
||||
final String? lastModified;
|
||||
|
||||
/// True when the page came from the local cache (304 or offline).
|
||||
final bool notModified;
|
||||
|
||||
/// Certificates the transports pinned on the way, to record on the main
|
||||
/// isolate: a host's first contact, or a renewal the old pin had expired
|
||||
/// for.
|
||||
final List<gemini.TofuUpdate> pinUpdates;
|
||||
}
|
||||
|
||||
class FetchFormOk extends FetchResult {
|
||||
|
|
@ -106,26 +161,128 @@ class FetchParseError extends FetchResult {
|
|||
final String source;
|
||||
}
|
||||
|
||||
/// A Gemini or Spartan status carrying a message for the reader: a temporary
|
||||
/// or permanent failure, a client or server error.
|
||||
class FetchProtocolError extends FetchResult {
|
||||
const FetchProtocolError(this.message);
|
||||
|
||||
final String message;
|
||||
}
|
||||
|
||||
/// Redirects went around more times than the transport follows.
|
||||
class FetchRedirectLoop extends FetchResult {
|
||||
const FetchRedirectLoop();
|
||||
}
|
||||
|
||||
/// A body the reader cannot be shown, of a type the client does not present.
|
||||
class FetchUnsupportedType extends FetchResult {
|
||||
const FetchUnsupportedType(this.mime);
|
||||
|
||||
final String mime;
|
||||
}
|
||||
|
||||
/// The server's certificate is not the one pinned for it, and the pin has not
|
||||
/// expired. The reader can still choose to trust what was presented.
|
||||
class FetchCertificateChanged extends FetchResult {
|
||||
const FetchCertificateChanged({
|
||||
required this.host,
|
||||
required this.port,
|
||||
required this.presented,
|
||||
});
|
||||
|
||||
final String host;
|
||||
final int port;
|
||||
final TofuPin presented;
|
||||
}
|
||||
|
||||
/// The raw body of a protocol request, for the renderer to show as an image.
|
||||
class FetchBytesOk extends FetchResult {
|
||||
const FetchBytesOk(this.data);
|
||||
|
||||
final Uint8List data;
|
||||
}
|
||||
|
||||
/// The site asked for a client certificate (Gemini 6x). [meta] says why; 61
|
||||
/// means an identity was offered and refused.
|
||||
class FetchClientCertificateRequired extends FetchResult {
|
||||
const FetchClientCertificateRequired({required this.status, required this.meta});
|
||||
|
||||
final int status;
|
||||
final String meta;
|
||||
}
|
||||
|
||||
class FetchHandler with Handler<FetchCommand, FetchResult> {
|
||||
// Identifies the client to servers that vary behavior by user agent, like
|
||||
// the http-to-https redirects some Mews sites run (spec 7.2).
|
||||
static const _userAgent = 'Mews/0.1 (Mews client)';
|
||||
|
||||
final http.Client _client = http.Client();
|
||||
final gemini.GeminiClient _gemini = gemini.GeminiClient();
|
||||
final spartan.SpartanClient _spartan = spartan.SpartanClient();
|
||||
|
||||
@override
|
||||
Future<FetchResult> handle(FetchCommand message) async {
|
||||
return switch (message) {
|
||||
FetchPage(:final url, :final etag, :final lastModified, :final cachedSource) =>
|
||||
await _fetchPage(url, etag, lastModified, cachedSource),
|
||||
FetchPage(
|
||||
:final url,
|
||||
:final etag,
|
||||
:final lastModified,
|
||||
:final cachedSource,
|
||||
:final pins,
|
||||
:final clientCert,
|
||||
:final clientKey,
|
||||
) =>
|
||||
await _fetchPage(url, etag, lastModified, cachedSource, pins, clientCert, clientKey),
|
||||
SubmitForm(:final url, :final method, :final fields) =>
|
||||
await _submitForm(url, method, fields),
|
||||
FetchFeed(:final url) => await _fetchFeed(url),
|
||||
ParseSource(:final url, :final source) => _parse(source, url),
|
||||
FetchBytes(:final url, :final pins, :final clientCert, :final clientKey) =>
|
||||
await _fetchBytes(url, pins, clientCert, clientKey),
|
||||
};
|
||||
}
|
||||
|
||||
Future<FetchResult> _fetchPage(
|
||||
/// Fetches bytes over a protocol, sharing the page fetch's trust and
|
||||
/// identity, and answers with the body alone: a body the reader cannot show
|
||||
/// as a page is not a failure to explain, just an image to place.
|
||||
Future<FetchResult> _fetchBytes(
|
||||
String url, Map<String, TofuPin> pins, String? clientCert, String? clientKey) async {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null) return const FetchNetworkError();
|
||||
try {
|
||||
if (uri.scheme == 'gemini') {
|
||||
final response = await _gemini.fetch(uri,
|
||||
pins: pins, clientCert: _identity(clientCert, clientKey));
|
||||
if (response.isSuccess) return FetchBytesOk(response.body ?? Uint8List(0));
|
||||
return const FetchNetworkError();
|
||||
}
|
||||
if (uri.scheme == 'spartan') {
|
||||
final response = await _spartan.fetch(uri);
|
||||
if (response.isSuccess) return FetchBytesOk(response.body ?? Uint8List(0));
|
||||
return const FetchNetworkError();
|
||||
}
|
||||
} on gemini.GeminiException {
|
||||
return const FetchNetworkError();
|
||||
} on spartan.SpartanException {
|
||||
return const FetchNetworkError();
|
||||
}
|
||||
return const FetchNetworkError();
|
||||
}
|
||||
|
||||
Future<FetchResult> _fetchPage(String url, String? etag, String? lastModified,
|
||||
String? cachedSource, Map<String, TofuPin> pins,
|
||||
[String? clientCert, String? clientKey]) async {
|
||||
switch (Uri.tryParse(url)?.scheme) {
|
||||
case 'gemini':
|
||||
return _fetchProtocol(url, cachedSource, pins, clientCert, clientKey);
|
||||
case 'spartan':
|
||||
return _fetchProtocol(url, cachedSource, pins, null, null);
|
||||
default:
|
||||
return _fetchWeb(url, etag, lastModified, cachedSource);
|
||||
}
|
||||
}
|
||||
|
||||
Future<FetchResult> _fetchWeb(
|
||||
String url, String? etag, String? lastModified, String? cachedSource) async {
|
||||
final headers = <String, String>{'User-Agent': _userAgent};
|
||||
if (etag != null) headers['If-None-Match'] = etag;
|
||||
|
|
@ -151,9 +308,11 @@ class FetchHandler with Handler<FetchCommand, FetchResult> {
|
|||
}
|
||||
|
||||
/// The page could not be revalidated; use the cached copy when there is one.
|
||||
FetchResult _fromCacheOrError(String url, String? cachedSource, String? etag, String? lastModified) {
|
||||
FetchResult _fromCacheOrError(
|
||||
String url, String? cachedSource, String? etag, String? lastModified) {
|
||||
if (cachedSource == null) return const FetchNetworkError();
|
||||
return _parse(cachedSource, url, etag: etag, lastModified: lastModified, notModified: true);
|
||||
return _parse(cachedSource, url,
|
||||
etag: etag, lastModified: lastModified, notModified: true);
|
||||
}
|
||||
|
||||
FetchResult _parse(String source, String url,
|
||||
|
|
@ -167,6 +326,159 @@ class FetchHandler with Handler<FetchCommand, FetchResult> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Fetches a Gemini or Spartan page. A protocol fetch has no validators, so
|
||||
/// the cache is only the offline fallback: the source is reparsed as
|
||||
/// gemtext, the format it was cached in.
|
||||
Future<FetchResult> _fetchProtocol(String url, String? cachedSource,
|
||||
Map<String, TofuPin> pins, String? clientCert, String? clientKey) async {
|
||||
FetchResult result;
|
||||
try {
|
||||
if (Uri.parse(url).scheme == 'gemini') {
|
||||
result = await _fetchGemini(Uri.parse(url), pins,
|
||||
identity: _identity(clientCert, clientKey));
|
||||
} else {
|
||||
result = await _fetchSpartan(Uri.parse(url));
|
||||
}
|
||||
} on gemini.GeminiCertificateChanged catch (e) {
|
||||
return FetchCertificateChanged(
|
||||
host: e.host, port: e.port, presented: e.presented);
|
||||
} on gemini.GeminiTooManyRedirects {
|
||||
return const FetchRedirectLoop();
|
||||
} on spartan.SpartanTooManyRedirects {
|
||||
return const FetchRedirectLoop();
|
||||
} on gemini.GeminiException {
|
||||
return _fromProtocolCacheOrError(url, cachedSource);
|
||||
} on spartan.SpartanException {
|
||||
return _fromProtocolCacheOrError(url, cachedSource);
|
||||
}
|
||||
|
||||
// The transport may have renewed a pin even on a failed request; a pin
|
||||
// that never got recorded is only pinned again on the next visit, which
|
||||
// is the same outcome, so only successes carry updates.
|
||||
return result;
|
||||
}
|
||||
|
||||
/// The reader's identity as the transport can wear it: a certificate and
|
||||
/// its key together, or nothing. One without the other is not worn at all.
|
||||
SecurityContext? _identity(String? certPem, String? keyPem) {
|
||||
if (certPem == null || keyPem == null) return null;
|
||||
return SecurityContext()
|
||||
..useCertificateChainBytes(utf8.encode(certPem))
|
||||
..usePrivateKeyBytes(utf8.encode(keyPem));
|
||||
}
|
||||
|
||||
Future<FetchResult> _fetchGemini(Uri url, Map<String, TofuPin> pins,
|
||||
{SecurityContext? identity}) async {
|
||||
final response = await _gemini.fetch(url, pins: pins, clientCert: identity);
|
||||
if (response.isSuccess) {
|
||||
return _protocolPage(response.url, response.meta, response.body, response.pinUpdates);
|
||||
}
|
||||
if (response.isRedirect) {
|
||||
// A redirect the client would not follow: somewhere other than
|
||||
// gemini, which the reader opens as its own navigation.
|
||||
return FetchProtocolError(response.meta);
|
||||
}
|
||||
if (response.status == 10 || response.status == 11) {
|
||||
return _inputPage(url, response.status, response.meta);
|
||||
}
|
||||
if (response.wantsClientCertificate) {
|
||||
return FetchClientCertificateRequired(status: response.status, meta: response.meta);
|
||||
}
|
||||
return FetchProtocolError(response.meta);
|
||||
}
|
||||
|
||||
Future<FetchResult> _fetchSpartan(Uri url) async {
|
||||
final response = await _spartan.fetch(url);
|
||||
if (response.isSuccess) {
|
||||
return _protocolPage(response.url,
|
||||
response.meta.isEmpty ? 'text/gemini' : response.meta, response.body, const []);
|
||||
}
|
||||
if (response.isRedirect) {
|
||||
return FetchProtocolError(response.meta);
|
||||
}
|
||||
return FetchProtocolError(response.meta);
|
||||
}
|
||||
|
||||
/// Builds a page from a protocol body by its MIME type: gemtext through the
|
||||
/// document model, other text plainly, images as an image block, and
|
||||
/// anything else refused rather than shown as garbage.
|
||||
FetchResult _protocolPage(Uri url, String meta, List<int>? body,
|
||||
List<gemini.TofuUpdate> pinUpdates) {
|
||||
final type = meta.split(';').first.trim().toLowerCase();
|
||||
if (type == 'text/gemini' || type.isEmpty) {
|
||||
final text = _decode(body);
|
||||
return FetchPageOk(
|
||||
page: parseGemtextPage(source: text, url: url.toString()),
|
||||
source: text,
|
||||
pinUpdates: pinUpdates);
|
||||
}
|
||||
if (type.startsWith('text/')) {
|
||||
final text = _decode(body);
|
||||
return FetchPageOk(
|
||||
page: MewsPage(
|
||||
url: url.toString(),
|
||||
specVersion: gemtextSpecVersion,
|
||||
lang: 'en',
|
||||
direction: TextDirection.ltr,
|
||||
variant: MewsVariant.plain,
|
||||
entries: const [],
|
||||
body: [PreBlock(text)],
|
||||
),
|
||||
source: text);
|
||||
}
|
||||
if (type.startsWith('image/')) {
|
||||
return FetchPageOk(
|
||||
page: MewsPage(
|
||||
url: url.toString(),
|
||||
specVersion: gemtextSpecVersion,
|
||||
lang: 'en',
|
||||
direction: TextDirection.ltr,
|
||||
variant: MewsVariant.plain,
|
||||
entries: const [],
|
||||
body: [ImageBlock(src: url.toString(), alt: '', allowed: true)],
|
||||
),
|
||||
// Binary bodies are not cached: the cache holds source text.
|
||||
source: '',
|
||||
pinUpdates: pinUpdates);
|
||||
}
|
||||
return FetchUnsupportedType(type);
|
||||
}
|
||||
|
||||
/// An input request (Gemini 1x) becomes a one-field form whose answer is
|
||||
/// sent back as the address's query string, so the reader answers within
|
||||
/// the page the same way a form submits.
|
||||
FetchResult _inputPage(Uri url, int status, String meta) {
|
||||
return FetchPageOk(
|
||||
page: MewsPage(
|
||||
url: url.toString(),
|
||||
specVersion: gemtextSpecVersion,
|
||||
lang: 'en',
|
||||
direction: TextDirection.ltr,
|
||||
variant: MewsVariant.plain,
|
||||
entries: const [],
|
||||
body: [
|
||||
FormBlock(
|
||||
action: url.toString(),
|
||||
method: 'get',
|
||||
fields: [MewsTextField(name: 'query', password: status == 11, label: meta)],
|
||||
submitLabel: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
source: '');
|
||||
}
|
||||
|
||||
FetchResult _fromProtocolCacheOrError(String url, String? cachedSource) {
|
||||
if (cachedSource == null || cachedSource.isEmpty) return const FetchNetworkError();
|
||||
return FetchPageOk(
|
||||
page: parseGemtextPage(source: cachedSource, url: url),
|
||||
source: cachedSource,
|
||||
notModified: true,
|
||||
);
|
||||
}
|
||||
|
||||
static String _decode(List<int>? bytes) => bytes == null ? '' : utf8.decode(bytes, allowMalformed: true);
|
||||
|
||||
Future<FetchResult> _fetchFeed(String url) async {
|
||||
final http.Response response;
|
||||
try {
|
||||
|
|
@ -190,6 +502,18 @@ class FetchHandler with Handler<FetchCommand, FetchResult> {
|
|||
}
|
||||
|
||||
Future<FetchResult> _submitForm(String url, String method, Map<String, String> fields) async {
|
||||
// A form on a protocol page answers as a query string, the protocols'
|
||||
// own way of carrying input.
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri != null && (uri.scheme == 'gemini' || uri.scheme == 'spartan')) {
|
||||
return _fetchProtocol(
|
||||
uri.replace(queryParameters: {...uri.queryParameters, ...fields}).toString(),
|
||||
null,
|
||||
const {},
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
final headers = {'User-Agent': _userAgent};
|
||||
final http.Response response;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import 'package:actors/actors.dart';
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:signals/signals.dart';
|
||||
|
||||
import '../../database/database.dart';
|
||||
import '../../mews/document.dart';
|
||||
import '../../protocols/tofu.dart' show TofuPin;
|
||||
import '../actors/fetch_actor.dart';
|
||||
import '../internal_actions.dart';
|
||||
import '../internal_pages.dart';
|
||||
import '../key_store.dart';
|
||||
import '../pages/mews_urls.dart';
|
||||
|
||||
/// What went wrong while browsing. The presentation layer maps these to
|
||||
|
|
@ -26,6 +30,52 @@ class BrokenPage extends BrowseError {
|
|||
final String source;
|
||||
}
|
||||
|
||||
/// A protocol status with the site's own message for the reader: a temporary
|
||||
/// or permanent failure, a client or server error.
|
||||
class ProtocolFailure extends BrowseError {
|
||||
const ProtocolFailure(this.message);
|
||||
|
||||
final String message;
|
||||
}
|
||||
|
||||
/// Redirects went around more times than the transports follow.
|
||||
class RedirectLoop extends BrowseError {
|
||||
const RedirectLoop();
|
||||
}
|
||||
|
||||
/// A body of a type the reader cannot show.
|
||||
class UnsupportedPage extends BrowseError {
|
||||
const UnsupportedPage(this.mime);
|
||||
|
||||
final String mime;
|
||||
}
|
||||
|
||||
/// The site asked for a client certificate (Gemini 6x). Choosing an identity
|
||||
/// is the reader's decision, made on a page of its own.
|
||||
class ClientCertificateRequired extends BrowseError {
|
||||
const ClientCertificateRequired({
|
||||
required this.host,
|
||||
required this.port,
|
||||
required this.status,
|
||||
required this.meta,
|
||||
});
|
||||
|
||||
final String host;
|
||||
final int port;
|
||||
final int status;
|
||||
final String meta;
|
||||
}
|
||||
|
||||
/// The site's certificate is not the one pinned for it, and the pin has not
|
||||
/// expired. The reader can still choose to trust what was presented.
|
||||
class CertificateChanged extends BrowseError {
|
||||
const CertificateChanged({required this.host, required this.port, required this.presented});
|
||||
|
||||
final String host;
|
||||
final int port;
|
||||
final TofuPin presented;
|
||||
}
|
||||
|
||||
/// Coordinates navigation: owns the signals the reader UI observes and
|
||||
/// dispatches fetch commands to the fetch actor. Local-first: a cached page is
|
||||
/// revalidated with the server but survives any network failure.
|
||||
|
|
@ -33,15 +83,18 @@ class BrowserController {
|
|||
BrowserController({
|
||||
required MewsDatabase db,
|
||||
required Actor<FetchCommand, FetchResult> fetch,
|
||||
required KeyStore keys,
|
||||
InternalPages? internalPages,
|
||||
InternalActions? internalActions,
|
||||
}) : _db = db,
|
||||
_fetch = fetch,
|
||||
_keys = keys,
|
||||
_internalPages = internalPages,
|
||||
_internalActions = internalActions;
|
||||
|
||||
final MewsDatabase _db;
|
||||
final Actor<FetchCommand, FetchResult> _fetch;
|
||||
final KeyStore _keys;
|
||||
final InternalPages? _internalPages;
|
||||
final InternalActions? _internalActions;
|
||||
|
||||
|
|
@ -108,13 +161,19 @@ class BrowserController {
|
|||
_updateNeighbors();
|
||||
await _loadCurrent();
|
||||
|
||||
// Mews sites commonly live on a wap. subdomain; try that before showing
|
||||
// an error when the address as typed does not work out.
|
||||
if (error.value != null && _stack[_stackIndex] == url) {
|
||||
final wap = wapUrlOf(url);
|
||||
// The address as typed is only the reader's first guess at where a site
|
||||
// lives. A Mews site commonly lives on a wap. subdomain, and a hostname
|
||||
// that answers on neither the web nor its wap. subdomain may still be a
|
||||
// capsule: gemini is tried next and spartan last. Only an address typed
|
||||
// without a scheme is offered the capsule guesses, because an explicit
|
||||
// scheme is the reader naming the place to go.
|
||||
var candidate = url;
|
||||
if (error.value != null && _stack[_stackIndex] == candidate) {
|
||||
final wap = wapUrlOf(candidate);
|
||||
if (wap != null) {
|
||||
final firstError = error.value;
|
||||
_stack[_stackIndex] = wap;
|
||||
candidate = wap;
|
||||
_stack[_stackIndex] = candidate;
|
||||
_updateNeighbors();
|
||||
await _loadCurrent();
|
||||
// The address as typed is the closer match; keep its error when the
|
||||
|
|
@ -124,6 +183,26 @@ class BrowserController {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (error.value != null && _stack[_stackIndex] == candidate) {
|
||||
final typed = input.trim();
|
||||
final web = Uri.tryParse(url);
|
||||
if (web != null && web.host.isNotEmpty && !_schemePattern.hasMatch(typed)) {
|
||||
final firstError = error.value;
|
||||
for (final scheme in ['gemini', 'spartan']) {
|
||||
candidate = Uri(scheme: scheme, host: web.host, path: web.path).toString();
|
||||
_stack[_stackIndex] = candidate;
|
||||
_updateNeighbors();
|
||||
await _loadCurrent();
|
||||
if (error.value == null) return;
|
||||
}
|
||||
// Nothing answered; the web address is the closest guess the reader
|
||||
// typed, so it is what the address bar shows with the first error.
|
||||
_stack[_stackIndex] = url;
|
||||
_updateNeighbors();
|
||||
if (firstError != null) error.value = firstError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> back() async {
|
||||
|
|
@ -195,6 +274,31 @@ class BrowserController {
|
|||
_stackIndex++;
|
||||
_updateNeighbors();
|
||||
_show(page);
|
||||
// A form on a protocol page, like an input request, answers with a
|
||||
// page of the protocol's own, which joins the walk like any fetched
|
||||
// page so going back returns to the question.
|
||||
case FetchPageOk(:final page, :final source, :final etag, :final lastModified, :final notModified, :final pinUpdates):
|
||||
for (final update in pinUpdates) {
|
||||
await _db.savePin(update.host, update.port, update.pin);
|
||||
}
|
||||
_stack.removeRange(_stackIndex + 1, _stack.length);
|
||||
_stack.add(page.url);
|
||||
_stackIndex++;
|
||||
_updateNeighbors();
|
||||
_show(page);
|
||||
if (!notModified && source.isNotEmpty) {
|
||||
await _db.saveCachedPage(CachedPagesCompanion.insert(
|
||||
url: page.url,
|
||||
source: source,
|
||||
title: Value(page.title),
|
||||
etag: Value(etag),
|
||||
lastModified: Value(lastModified),
|
||||
));
|
||||
}
|
||||
case FetchProtocolError(:final message):
|
||||
error.value = ProtocolFailure(message);
|
||||
case FetchCertificateChanged(:final host, :final port, :final presented):
|
||||
error.value = CertificateChanged(host: host, port: port, presented: presented);
|
||||
case _:
|
||||
error.value = const LoadFailed();
|
||||
}
|
||||
|
|
@ -264,23 +368,33 @@ class BrowserController {
|
|||
}
|
||||
|
||||
final cached = await _db.cachedPage(url);
|
||||
final uri = Uri.tryParse(url);
|
||||
final credentials = await _geminiCredentials(uri);
|
||||
final result = await _fetch.send(FetchPage(
|
||||
url: url,
|
||||
etag: cached?.etag,
|
||||
lastModified: cached?.lastModified,
|
||||
cachedSource: cached?.source,
|
||||
pins: credentials.pins,
|
||||
clientCert: credentials.clientCert,
|
||||
clientKey: credentials.clientKey,
|
||||
));
|
||||
loading.value = false;
|
||||
|
||||
switch (result) {
|
||||
case FetchPageOk(:final page, :final source, :final etag, :final lastModified, :final notModified):
|
||||
case FetchPageOk(:final page, :final source, :final etag, :final lastModified, :final notModified, :final pinUpdates):
|
||||
currentPage.value = page;
|
||||
error.value = null;
|
||||
visitedUrls.value = {...visitedUrls.value, url};
|
||||
// Opening a post is reading it. Nothing happens when the address is
|
||||
// not one, so this needs no knowledge of the reader's subscriptions.
|
||||
await _db.markEntryRead(url);
|
||||
if (!notModified) {
|
||||
for (final update in pinUpdates) {
|
||||
await _db.savePin(update.host, update.port, update.pin);
|
||||
}
|
||||
// An empty source is a body with no text worth keeping, like an
|
||||
// image; there is nothing to cache.
|
||||
if (!notModified && source.isNotEmpty) {
|
||||
await _db.saveCachedPage(CachedPagesCompanion.insert(
|
||||
url: url,
|
||||
source: source,
|
||||
|
|
@ -293,12 +407,81 @@ class BrowserController {
|
|||
case FetchParseError(:final source):
|
||||
currentPage.value = null;
|
||||
error.value = BrokenPage(source);
|
||||
case FetchProtocolError(:final message):
|
||||
currentPage.value = null;
|
||||
error.value = ProtocolFailure(message);
|
||||
case FetchRedirectLoop():
|
||||
currentPage.value = null;
|
||||
error.value = const RedirectLoop();
|
||||
case FetchUnsupportedType(:final mime):
|
||||
currentPage.value = null;
|
||||
error.value = UnsupportedPage(mime);
|
||||
case FetchCertificateChanged(:final host, :final port, :final presented):
|
||||
currentPage.value = null;
|
||||
error.value = CertificateChanged(host: host, port: port, presented: presented);
|
||||
case FetchClientCertificateRequired(:final status, :final meta):
|
||||
currentPage.value = null;
|
||||
error.value = ClientCertificateRequired(
|
||||
host: uri?.host ?? '', port: uri != null && uri.hasPort ? uri.port : 1965,
|
||||
status: status, meta: meta);
|
||||
case _:
|
||||
currentPage.value = null;
|
||||
error.value = const LoadFailed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the certificate the error page is showing as the new pin and
|
||||
/// reloads. Only the reader can make this choice, from the error page.
|
||||
Future<void> trustPresentedCertificate() async {
|
||||
final shown = error.value;
|
||||
if (shown is! CertificateChanged) return;
|
||||
await _db.savePin(shown.host, shown.port, shown.presented);
|
||||
await _loadCurrent();
|
||||
}
|
||||
|
||||
/// Binds the certificate the reader picked to the site asking for one and
|
||||
/// reloads, so the site sees the identity on the connection that follows.
|
||||
Future<void> chooseCertificate(int certificateId) async {
|
||||
final shown = error.value;
|
||||
if (shown is! ClientCertificateRequired) return;
|
||||
await _db.bindCertificate(shown.host, shown.port, certificateId);
|
||||
await _loadCurrent();
|
||||
}
|
||||
|
||||
/// The raw bytes of a body on a protocol page, for the renderer to show an
|
||||
/// image. There is no address the platform could load it from, so it comes
|
||||
/// through the fetch actor wearing the same trust and identity as the page.
|
||||
Future<Uint8List?> fetchImageBytes(String url) async {
|
||||
final credentials = await _geminiCredentials(Uri.tryParse(url));
|
||||
final result = await _fetch.send(FetchBytes(
|
||||
url: url,
|
||||
pins: credentials.pins,
|
||||
clientCert: credentials.clientCert,
|
||||
clientKey: credentials.clientKey,
|
||||
));
|
||||
return result is FetchBytesOk ? result.data : null;
|
||||
}
|
||||
|
||||
/// What a fetch of [uri] over gemini needs: the reader's pinned hosts,
|
||||
/// which live on this isolate's database and not in the actor's, and the
|
||||
/// identity bound to this host, worn only there.
|
||||
Future<({Map<String, TofuPin> pins, String? clientCert, String? clientKey})>
|
||||
_geminiCredentials(Uri? uri) async {
|
||||
if (uri == null || uri.scheme != 'gemini') {
|
||||
return (pins: const <String, TofuPin>{}, clientCert: null, clientKey: null);
|
||||
}
|
||||
final pins = await _db.allPins();
|
||||
final port = uri.hasPort ? uri.port : 1965;
|
||||
final bound = await _db.bindingFor(uri.host, port);
|
||||
if (bound == null) return (pins: pins, clientCert: null, clientKey: null);
|
||||
final held = await _db.clientCertificate(bound);
|
||||
return (
|
||||
pins: pins,
|
||||
clientCert: held?.certPem,
|
||||
clientKey: await _keys.read(bound),
|
||||
);
|
||||
}
|
||||
|
||||
void _show(MewsPage page) {
|
||||
currentPage.value = page;
|
||||
_db.recordVisit(page.url, page.title);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../database/database.dart';
|
||||
import '../l10n/strings.dart';
|
||||
import 'controllers/bookmarks_controller.dart';
|
||||
import 'controllers/reader_settings_controller.dart'
|
||||
show ReaderSettingsController;
|
||||
import 'controllers/subscriptions_controller.dart';
|
||||
import 'key_store.dart';
|
||||
import 'pages/mews_urls.dart';
|
||||
import 'pem.dart';
|
||||
|
||||
/// Applies the change an action address encodes, e.g. mews:do/set-theme.
|
||||
/// Returns whether anything changed, so the navigator can skip a rerender that
|
||||
|
|
@ -12,6 +20,10 @@ import 'pages/mews_urls.dart';
|
|||
/// An unknown verb or a missing argument does nothing and reports nothing: the
|
||||
/// only links carrying these addresses are the ones the client builds, so a
|
||||
/// miss is a bug rather than something to explain to the reader.
|
||||
/// A PEM file the reader picked, or null when the pick was cancelled. [name]
|
||||
/// names the identity after its file, which is what the reader chose.
|
||||
typedef PickedPem = ({String name, String content});
|
||||
|
||||
class InternalActions {
|
||||
InternalActions({
|
||||
required MewsDatabase db,
|
||||
|
|
@ -19,17 +31,42 @@ class InternalActions {
|
|||
required ReaderSettingsController settings,
|
||||
required BookmarksController bookmarks,
|
||||
required SubscriptionsController subscriptions,
|
||||
required KeyStore keys,
|
||||
required Strings strings,
|
||||
Future<PickedPem?> Function(String title)? pickPemFile,
|
||||
}) : _db = db,
|
||||
_applyLanguage = applyLanguage,
|
||||
_settings = settings,
|
||||
_bookmarks = bookmarks,
|
||||
_subscriptions = subscriptions;
|
||||
_subscriptions = subscriptions,
|
||||
_keys = keys,
|
||||
_strings = strings,
|
||||
_pickPemFile = pickPemFile ?? _pickPemFromFileSystem;
|
||||
|
||||
final MewsDatabase _db;
|
||||
final Future<void> Function() _applyLanguage;
|
||||
final ReaderSettingsController _settings;
|
||||
final BookmarksController _bookmarks;
|
||||
final SubscriptionsController _subscriptions;
|
||||
final KeyStore _keys;
|
||||
final Strings _strings;
|
||||
final Future<PickedPem?> Function(String title) _pickPemFile;
|
||||
|
||||
/// Opens the platform file picker for one PEM file, titled with what the
|
||||
/// step asks for. A single file may hold both the certificate and its key,
|
||||
/// or either one alone. A cancelled pick comes back as nothing.
|
||||
static Future<PickedPem?> _pickPemFromFileSystem(String title) async {
|
||||
final files = await FilePicker.pickFiles(
|
||||
dialogTitle: title,
|
||||
type: FileType.custom,
|
||||
allowedExtensions: const ['pem', 'crt', 'cer', 'key']);
|
||||
final path = files.singleOrNull?.path;
|
||||
if (path == null) return null;
|
||||
return (
|
||||
name: p.basenameWithoutExtension(path),
|
||||
content: await File(path).readAsString(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> apply(String url) async {
|
||||
final verb = MewsUrls.verbOf(url);
|
||||
|
|
@ -52,10 +89,55 @@ class InternalActions {
|
|||
'add-tag' => _changeTag(args, add: true),
|
||||
'remove-tag' => _changeTag(args, add: false),
|
||||
'delete-tag' => _deleteTag(args['tag']),
|
||||
'import-certificate' => _importCertificate(),
|
||||
'rename-certificate' => _renameCertificate(args),
|
||||
'delete-certificate' => _deleteCertificate(args),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Reads a certificate and its key from picked PEM files. The first pick
|
||||
/// may hold both; when it holds only one, the other is asked for again, by
|
||||
/// name, so a key picked on its own is not silently dropped. A cancelled
|
||||
/// pick imports nothing.
|
||||
Future<bool> _importCertificate() async {
|
||||
final first = await _pickPemFile(_strings.t('certificates-pick-title'));
|
||||
if (first == null) return false;
|
||||
String? cert = certificateOf(first.content);
|
||||
String? key = privateKeyOf(first.content);
|
||||
if (cert == null || key == null) {
|
||||
final asking = cert == null
|
||||
? _strings.t('certificates-pick-cert-title')
|
||||
: _strings.t('certificates-pick-key-title');
|
||||
final second = await _pickPemFile(asking);
|
||||
if (second == null) return false;
|
||||
cert ??= certificateOf(second.content);
|
||||
key ??= privateKeyOf(second.content);
|
||||
}
|
||||
if (cert == null || key == null) return false;
|
||||
final name = first.name.trim();
|
||||
final id = await _db.addClientCertificate(
|
||||
name.isEmpty ? _strings.t('certificates-default-name') : name, cert);
|
||||
await _keys.save(id, key);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> _renameCertificate(Map<String, String> args) async {
|
||||
final id = int.tryParse(args['id'] ?? '');
|
||||
final name = args['name']?.trim();
|
||||
if (id == null || name == null || name.isEmpty) return false;
|
||||
await _db.renameClientCertificate(id, name);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> _deleteCertificate(Map<String, String> args) async {
|
||||
final id = int.tryParse(args['id'] ?? '');
|
||||
if (id == null) return false;
|
||||
await _keys.delete(id);
|
||||
await _db.deleteClientCertificate(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Changing the language has to load its messages before the page is built
|
||||
/// again, or the rerender would still read the old ones.
|
||||
Future<bool> _setLanguage(String? tag) async {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'controllers/reader_settings_controller.dart'
|
|||
show ReaderSettingsController;
|
||||
import 'controllers/subscriptions_controller.dart';
|
||||
import 'pages/bookmarks_page.dart';
|
||||
import 'pages/certificates_page.dart';
|
||||
import 'pages/history_page.dart';
|
||||
import 'pages/hub_page.dart';
|
||||
import 'pages/mews_urls.dart';
|
||||
|
|
@ -52,6 +53,8 @@ class InternalPages {
|
|||
'tags' => buildTagsPage(uri, db: _db, strings: _strings),
|
||||
'settings' =>
|
||||
buildSettingsPage(uri, settings: _settings, strings: _strings),
|
||||
'certificates' =>
|
||||
buildCertificatesPage(uri, db: _db, strings: _strings),
|
||||
_ => Future.value(null),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
41
app/lib/application/key_store.dart
Normal file
41
app/lib/application/key_store.dart
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import 'dart:io';
|
||||
|
||||
/// The private keys of imported client certificates, as PEM files in the app's
|
||||
/// own storage. The database keeps the public certificate; the key stays out
|
||||
/// of it, so a copy of the database never carries a reader's identity.
|
||||
///
|
||||
/// The operations are synchronous on purpose: they touch one small file at a
|
||||
/// time, only when the reader imports or uses an identity, and staying off the
|
||||
/// event loop keeps them completing within a single interaction.
|
||||
class KeyStore {
|
||||
KeyStore(this._root);
|
||||
|
||||
/// The directory the keys live in, resolved lazily because finding it asks
|
||||
/// the platform.
|
||||
final Future<Directory> Function() _root;
|
||||
|
||||
Future<File> _file(int id) async {
|
||||
final root = await _root();
|
||||
return File('${root.path}${Platform.pathSeparator}$id.pem');
|
||||
}
|
||||
|
||||
/// Stores the key of the certificate with this id.
|
||||
Future<void> save(int id, String pem) async {
|
||||
final file = await _file(id);
|
||||
file.parent.createSync(recursive: true);
|
||||
file.writeAsStringSync(pem);
|
||||
}
|
||||
|
||||
/// The key of the certificate with this id, or null when there is none.
|
||||
Future<String?> read(int id) async {
|
||||
final file = await _file(id);
|
||||
if (!file.existsSync()) return null;
|
||||
return file.readAsStringSync();
|
||||
}
|
||||
|
||||
/// Removes the key of the certificate with this id.
|
||||
Future<void> delete(int id) async {
|
||||
final file = await _file(id);
|
||||
if (file.existsSync()) file.deleteSync();
|
||||
}
|
||||
}
|
||||
82
app/lib/application/pages/certificates_page.dart
Normal file
82
app/lib/application/pages/certificates_page.dart
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import '../../database/database.dart';
|
||||
import '../../l10n/strings.dart';
|
||||
import '../../mews/document.dart';
|
||||
import 'mews_urls.dart';
|
||||
import 'page_blocks.dart';
|
||||
|
||||
/// The reader's certificates, worn as identities on Gemini sites that ask for
|
||||
/// one. An identity is added from a PEM file, renamed in place, and removed
|
||||
/// in a second step when it is worn anywhere, because the sites that know it
|
||||
/// lose it with the removal.
|
||||
Future<MewsPage> buildCertificatesPage(
|
||||
Uri url, {
|
||||
required MewsDatabase db,
|
||||
required Strings strings,
|
||||
}) async {
|
||||
final title = strings.t('certificates-title');
|
||||
final held = await db.allClientCertificates();
|
||||
|
||||
final body = <MewsBlock>[
|
||||
...chrome(title: title, current: url, strings: strings),
|
||||
ParagraphBlock([
|
||||
MewsLink(MewsUrls.action('import-certificate'), [
|
||||
MewsText(strings.t('certificates-add')),
|
||||
]),
|
||||
]),
|
||||
];
|
||||
|
||||
// Removing an identity takes a second step, like clearing history: the
|
||||
// sites it is worn at stop seeing it too, and that cannot be undone.
|
||||
final forgetting = int.tryParse(url.queryParameters['forget'] ?? '');
|
||||
final row = forgetting == null ? null : await db.clientCertificate(forgetting);
|
||||
if (row != null) {
|
||||
body.addAll([
|
||||
HeadingBlock(2, [MewsText(row.name)]),
|
||||
ParagraphBlock([MewsText(strings.t('certificates-delete-confirm'))]),
|
||||
...linkLine([
|
||||
action(
|
||||
MewsUrls.action('delete-certificate', {'id': '${row.id}'}),
|
||||
strings.t('certificates-delete-yes')),
|
||||
action(MewsUrls.certificates, strings.t('certificates-delete-no')),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
if (held.isEmpty) {
|
||||
body.add(ParagraphBlock([MewsText(strings.t('certificates-empty'))]));
|
||||
} else {
|
||||
final rows = <MewsListItem>[];
|
||||
for (final certificate in held) {
|
||||
final worn = await db.bindingsOf(certificate.id);
|
||||
rows.add(MewsListItem([
|
||||
ParagraphBlock([
|
||||
MewsEmphasis(EmphasisStyle.strong, [MewsText(certificate.name)]),
|
||||
if (worn.isNotEmpty) ...[
|
||||
const MewsLineBreak(),
|
||||
MewsEmphasis(
|
||||
EmphasisStyle.emphasis,
|
||||
[
|
||||
MewsText(worn
|
||||
.map((host) => strings.t('certificates-bound-to', {'host': host}))
|
||||
.join(actionSeparator)),
|
||||
],
|
||||
),
|
||||
],
|
||||
const MewsLineBreak(),
|
||||
MewsLink(MewsUrls.page(url, {'forget': '${certificate.id}'}),
|
||||
[MewsText(strings.t('certificates-remove'))]),
|
||||
]),
|
||||
textForm(
|
||||
action: MewsUrls.action('rename-certificate', {'id': '${certificate.id}'}),
|
||||
field: 'name',
|
||||
label: strings.t('certificates-rename-label'),
|
||||
submitLabel: strings.t('certificates-rename-submit'),
|
||||
value: certificate.name,
|
||||
),
|
||||
]));
|
||||
}
|
||||
body.add(itemList(rows));
|
||||
}
|
||||
|
||||
return internalPage(url: url, title: title, body: body, strings: strings);
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ abstract final class MewsUrls {
|
|||
static const history = 'mews:history';
|
||||
static const tags = 'mews:tags';
|
||||
static const settings = 'mews:settings';
|
||||
static const certificates = 'mews:certificates';
|
||||
|
||||
/// Whether [url] belongs to the internal scheme.
|
||||
static bool isInternal(String url) => Uri.tryParse(url)?.scheme == scheme;
|
||||
|
|
|
|||
|
|
@ -107,6 +107,18 @@ Future<MewsPage> buildSettingsPage(
|
|||
current: variant == settings.variant.value,
|
||||
),
|
||||
]),
|
||||
// Identities are their own page: a certificate is added from a file,
|
||||
// which has no place among the presentation choices.
|
||||
HeadingBlock(2, [MewsText(strings.t('settings-identities-heading'))]),
|
||||
itemList([
|
||||
MewsListItem([
|
||||
ParagraphBlock([
|
||||
MewsLink(MewsUrls.certificates, [
|
||||
MewsText(strings.t('certificates-title')),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
16
app/lib/application/pem.dart
Normal file
16
app/lib/application/pem.dart
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/// Splits PEM material into its two parts. A Gemini identity commonly travels
|
||||
/// as one .pem holding both the certificate and its private key, or as two
|
||||
/// files; both cases come out of these two lookups.
|
||||
library;
|
||||
|
||||
final _certificate = RegExp(
|
||||
r'-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----');
|
||||
|
||||
final _privateKey = RegExp(
|
||||
r'-----BEGIN (?:RSA |EC )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC )?PRIVATE KEY-----');
|
||||
|
||||
/// The first certificate in [pem], or null when there is none.
|
||||
String? certificateOf(String pem) => _certificate.firstMatch(pem)?.group(0);
|
||||
|
||||
/// The first private key in [pem], or null when there is none.
|
||||
String? privateKeyOf(String pem) => _privateKey.firstMatch(pem)?.group(0);
|
||||
Loading…
Add table
Add a link
Reference in a new issue