From fa7aa5557c953532d88dbcd24f3a69ec168f0120 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 11 Oct 2026 22:07:25 +0300 Subject: [PATCH] feat: add Gemini and Spartan protocol support --- SPEC.md | 16 +- app/lib/application/actors/fetch_actor.dart | 344 +++- .../controllers/browser_controller.dart | 197 ++- app/lib/application/internal_actions.dart | 84 +- app/lib/application/internal_pages.dart | 3 + app/lib/application/key_store.dart | 41 + .../application/pages/certificates_page.dart | 82 + app/lib/application/pages/mews_urls.dart | 1 + app/lib/application/pages/settings_page.dart | 12 + app/lib/application/pem.dart | 16 + app/lib/database/database.dart | 135 +- app/lib/database/database.g.dart | 1502 +++++++++++++++++ app/lib/mews/gemtext.dart | 154 ++ app/lib/presentation/app.dart | 19 +- app/lib/presentation/reader/address_bar.dart | 220 +-- .../presentation/reader/mews_page_view.dart | 53 +- .../presentation/reader/reader_screen.dart | 134 +- app/lib/protocols/gemini.dart | 216 +++ app/lib/protocols/spartan.dart | 129 ++ app/lib/protocols/tofu.dart | 56 + app/locales/de/main.ftl | 31 +- app/locales/en-US/main.ftl | 34 +- app/locales/es/main.ftl | 31 +- app/locales/fr/main.ftl | 31 +- app/locales/he/main.ftl | 31 +- app/locales/ja/main.ftl | 31 +- app/locales/pt/main.ftl | 31 +- app/locales/ru/main.ftl | 31 +- app/pubspec.lock | 95 +- app/pubspec.yaml | 4 +- app/test/browser_controller_test.dart | 103 ++ app/test/certificates_page_test.dart | 211 +++ app/test/database_test.dart | 38 + app/test/fake_fetch.dart | 77 +- app/test/fake_protocol_server.dart | 93 + app/test/fetch_actor_test.dart | 171 ++ app/test/fixtures/client-test-cert.pem | 17 + app/test/fixtures/client-test-key.pem | 28 + app/test/fixtures/gemini-test-a-cert.pem | 17 + app/test/fixtures/gemini-test-a-key.pem | 28 + app/test/fixtures/gemini-test-b-cert.pem | 17 + app/test/fixtures/gemini-test-b-key.pem | 28 + app/test/gemini_test.dart | 163 ++ app/test/gemtext_test.dart | 109 ++ app/test/key_store_test.dart | 33 + app/test/reader_widget_test.dart | 84 +- app/test/spartan_test.dart | 129 ++ app/test/tofu_test.dart | 43 + 48 files changed, 4992 insertions(+), 161 deletions(-) create mode 100644 app/lib/application/key_store.dart create mode 100644 app/lib/application/pages/certificates_page.dart create mode 100644 app/lib/application/pem.dart create mode 100644 app/lib/mews/gemtext.dart create mode 100644 app/lib/protocols/gemini.dart create mode 100644 app/lib/protocols/spartan.dart create mode 100644 app/lib/protocols/tofu.dart create mode 100644 app/test/browser_controller_test.dart create mode 100644 app/test/certificates_page_test.dart create mode 100644 app/test/fake_protocol_server.dart create mode 100644 app/test/fixtures/client-test-cert.pem create mode 100644 app/test/fixtures/client-test-key.pem create mode 100644 app/test/fixtures/gemini-test-a-cert.pem create mode 100644 app/test/fixtures/gemini-test-a-key.pem create mode 100644 app/test/fixtures/gemini-test-b-cert.pem create mode 100644 app/test/fixtures/gemini-test-b-key.pem create mode 100644 app/test/gemini_test.dart create mode 100644 app/test/gemtext_test.dart create mode 100644 app/test/key_store_test.dart create mode 100644 app/test/spartan_test.dart create mode 100644 app/test/tofu_test.dart diff --git a/SPEC.md b/SPEC.md index aa1d361..8b61c3c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -14,7 +14,7 @@ Flutter version: `3.41.9-sdk-links` (keep in sync with devbox.json). ## What it is -A Flutter browser/client for Mews Pages (see mews-spec). +A Flutter browser/client for Mews pages (see mews-spec), Gemini capsules and Spartan sites. ## Approach @@ -22,6 +22,20 @@ Local-first: all data is stored locally in a Drift database. Network operations Posts found on a subscribed page are stored per site with a read mark, so an unread post survives a restart. A post's address identifies it for its lifetime (spec 6.1), so finding it again never clears its mark. Subscribing to a site stores what it already published as read, so a new subscription arrives without a backlog. +## Protocols + +The client speaks `gemini://` and `spartan://` alongside the web. A page over either is gemtext, which converts into the same document model a Mews page parses into, so it renders through the same reader presentation, joins the history and the swipes, and can be bookmarked and subscribed to; a link label that begins with a date is a dated entry, so subscriptions work on capsules too. A body's MIME type decides what becomes of it: `text/gemini` is converted, other text is shown plainly as preformatted text, an image is shown in place, and anything else is refused rather than shown as garbage. + +Gemini runs over TLS on port 1965; Spartan runs over plain TCP on port 300 with a request line of host, path and body length. Redirects are followed up to five times and only within the same protocol; a redirect pointing elsewhere is left for the reader to open as their own navigation. Neither protocol has validators, so a cached page is only the offline fallback, never a revalidation. An input request (Gemini 1x) is rendered as a one-field form whose answer rides back as the address's query string; a sensitive input (11) asks quietly. + +Servers present self-signed certificates, so the client decides what to trust itself: the first visit to a host pins the certificate it saw, and every later visit compares against that pin. A change while the pinned certificate has expired is the routine renewal and is accepted silently; a change while it is still valid is refused with an error page offering the reader the choice to trust the new certificate. Trust is per host and port. + +A client certificate is an identity. Certificates are imported from PEM files, one file holding both the certificate and its key or two holding one each, and are named after their file. They are managed on `mews:certificates`, linked from the settings page; removing one takes a second step, because the sites it is worn at lose it too. A certificate is bound to one host at a time and worn only there, and it is presented only when the site asks (Gemini 60): the answer is a picker naming each identity, and the choice is remembered for the next visit. A site that refuses the certificate presented (61) or calls it invalid (62) gets an error page explaining so, with the site's own words under it. Private keys live as files in the app's own storage, never in the database. + +An address typed without a scheme is tried as `https` first, then on its `wap.` subdomain, then over `gemini`, then over `spartan`, before an error is shown; an address carrying a scheme is never second-guessed. When nothing answers, the first error is the one shown, beside the web address. + +An image over a protocol has no address the platform could load, so its bytes are fetched through the same transport, wearing the same trust and identity as the page it appears on, and decoded in place; protocol images are never cached, so they arrive fresh on each visit. A missing image falls back to its alt text, the way a missing web image does. + # Architecture directives Keep files small and focused. diff --git a/app/lib/application/actors/fetch_actor.dart b/app/lib/application/actors/fetch_actor.dart index 0f27581..d36e508 100644 --- a/app/lib/application/actors/fetch_actor.dart +++ b/app/lib/application/actors/fetch_actor.dart @@ -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 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 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 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 { // 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 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 _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 _fetchBytes( + String url, Map 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 _fetchPage(String url, String? etag, String? lastModified, + String? cachedSource, Map 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 _fetchWeb( String url, String? etag, String? lastModified, String? cachedSource) async { final headers = {'User-Agent': _userAgent}; if (etag != null) headers['If-None-Match'] = etag; @@ -151,9 +308,11 @@ class FetchHandler with Handler { } /// 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 { } } + /// 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 _fetchProtocol(String url, String? cachedSource, + Map 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 _fetchGemini(Uri url, Map 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 _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? body, + List 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? bytes) => bytes == null ? '' : utf8.decode(bytes, allowMalformed: true); + Future _fetchFeed(String url) async { final http.Response response; try { @@ -190,6 +502,18 @@ class FetchHandler with Handler { } Future _submitForm(String url, String method, Map 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 { diff --git a/app/lib/application/controllers/browser_controller.dart b/app/lib/application/controllers/browser_controller.dart index 3cec7f8..d41b3f4 100644 --- a/app/lib/application/controllers/browser_controller.dart +++ b/app/lib/application/controllers/browser_controller.dart @@ -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 fetch, + required KeyStore keys, InternalPages? internalPages, InternalActions? internalActions, }) : _db = db, _fetch = fetch, + _keys = keys, _internalPages = internalPages, _internalActions = internalActions; final MewsDatabase _db; final Actor _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 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 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 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 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 pins, String? clientCert, String? clientKey})> + _geminiCredentials(Uri? uri) async { + if (uri == null || uri.scheme != 'gemini') { + return (pins: const {}, 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); diff --git a/app/lib/application/internal_actions.dart b/app/lib/application/internal_actions.dart index e227fda..eb09637 100644 --- a/app/lib/application/internal_actions.dart +++ b/app/lib/application/internal_actions.dart @@ -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 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 Function() _applyLanguage; final ReaderSettingsController _settings; final BookmarksController _bookmarks; final SubscriptionsController _subscriptions; + final KeyStore _keys; + final Strings _strings; + final Future 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 _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 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 _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 _renameCertificate(Map 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 _deleteCertificate(Map 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 _setLanguage(String? tag) async { diff --git a/app/lib/application/internal_pages.dart b/app/lib/application/internal_pages.dart index 1f85f49..7076d04 100644 --- a/app/lib/application/internal_pages.dart +++ b/app/lib/application/internal_pages.dart @@ -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), }; } diff --git a/app/lib/application/key_store.dart b/app/lib/application/key_store.dart new file mode 100644 index 0000000..a20b1bc --- /dev/null +++ b/app/lib/application/key_store.dart @@ -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 Function() _root; + + Future _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 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 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 delete(int id) async { + final file = await _file(id); + if (file.existsSync()) file.deleteSync(); + } +} diff --git a/app/lib/application/pages/certificates_page.dart b/app/lib/application/pages/certificates_page.dart new file mode 100644 index 0000000..1ca5735 --- /dev/null +++ b/app/lib/application/pages/certificates_page.dart @@ -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 buildCertificatesPage( + Uri url, { + required MewsDatabase db, + required Strings strings, +}) async { + final title = strings.t('certificates-title'); + final held = await db.allClientCertificates(); + + final body = [ + ...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 = []; + 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); +} diff --git a/app/lib/application/pages/mews_urls.dart b/app/lib/application/pages/mews_urls.dart index 9412c20..05038f4 100644 --- a/app/lib/application/pages/mews_urls.dart +++ b/app/lib/application/pages/mews_urls.dart @@ -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; diff --git a/app/lib/application/pages/settings_page.dart b/app/lib/application/pages/settings_page.dart index eaa3343..03a897a 100644 --- a/app/lib/application/pages/settings_page.dart +++ b/app/lib/application/pages/settings_page.dart @@ -107,6 +107,18 @@ Future 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')), + ]), + ]), + ]), + ]), ], ); } diff --git a/app/lib/application/pem.dart b/app/lib/application/pem.dart new file mode 100644 index 0000000..9305b5a --- /dev/null +++ b/app/lib/application/pem.dart @@ -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); diff --git a/app/lib/database/database.dart b/app/lib/database/database.dart index 46f8cff..8bf3724 100644 --- a/app/lib/database/database.dart +++ b/app/lib/database/database.dart @@ -6,6 +6,7 @@ import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import '../mews/document.dart' show MewsEntry; +import '../protocols/tofu.dart' show TofuPin; part 'database.g.dart'; @@ -101,6 +102,42 @@ class CachedPages extends Table { Set get primaryKey => {url}; } +/// A Gemini host the reader's first contact with it pinned, together with the +/// certificate that was accepted (see lib/protocols/tofu.dart). The pin is +/// the trust: a later visit compares against it, and a change is accepted +/// only when the pin has expired or the reader chose to trust the new one. +class KnownHosts extends Table { + TextColumn get host => text()(); + IntColumn get port => integer()(); + TextColumn get fingerprint => text()(); + DateTimeColumn get expires => dateTime()(); + DateTimeColumn get pinnedAt => dateTime().withDefault(currentDateAndTime)(); + + @override + Set get primaryKey => {host, port}; +} + +/// A client certificate the reader holds, for wearing as an identity on sites +/// that ask for one. The certificate is public record; its private key lives +/// in a file of the app's own storage, never in this database. +class ClientCertificates extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get name => text()(); + TextColumn get certPem => text()(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); +} + +/// Which certificate the reader presents to one host: a binding the reader +/// made on a certificate request, worn only there. +class SiteCertificates extends Table { + TextColumn get host => text()(); + IntColumn get port => integer()(); + IntColumn get certificateId => integer()(); + + @override + Set get primaryKey => {host, port}; +} + @DriftDatabase(tables: [ HistoryItems, Bookmarks, @@ -109,6 +146,9 @@ class CachedPages extends Table { ItemTags, ReaderSettings, CachedPages, + KnownHosts, + ClientCertificates, + SiteCertificates, ]) class MewsDatabase extends _$MewsDatabase { MewsDatabase() : super(_open()); @@ -117,7 +157,7 @@ class MewsDatabase extends _$MewsDatabase { MewsDatabase.test(super.executor); @override - int get schemaVersion => 3; + int get schemaVersion => 5; @override MigrationStrategy get migration => MigrationStrategy( @@ -136,6 +176,13 @@ class MewsDatabase extends _$MewsDatabase { if (from < 3) { await m.addColumn(readerSettings, readerSettings.language); } + if (from < 4) { + await m.createTable(knownHosts); + } + if (from < 5) { + await m.createTable(clientCertificates); + await m.createTable(siteCertificates); + } }, ); @@ -557,6 +604,92 @@ class MewsDatabase extends _$MewsDatabase { Future saveCachedPage(CachedPagesCompanion entry) => into(cachedPages).insert(entry, mode: InsertMode.insertOrReplace); + + // Known hosts --------------------------------------------------------------- + + /// Every pinned host, keyed `host:port`, for the fetch that checks each of + /// them, redirect targets included. The table holds only hosts the reader + /// has actually visited, so it is small enough to hand over whole. + Future> allPins() async { + final rows = await select(knownHosts).get(); + return { + for (final row in rows) + '${row.host}:${row.port}': + TofuPin(fingerprint: row.fingerprint, expires: row.expires), + }; + } + + /// Pins a host's certificate: the first time the host was seen, a renewal + /// the old pin had expired for, or the reader choosing to trust a change. + Future savePin(String host, int port, TofuPin pin) => + into(knownHosts).insert( + KnownHostsCompanion.insert( + host: host, + port: port, + fingerprint: pin.fingerprint, + expires: pin.expires, + ), + mode: InsertMode.insertOrReplace); + + // Identities --------------------------------------------------------------- + + /// Every certificate the reader holds, oldest first. + Future> allClientCertificates() => + (select(clientCertificates)..orderBy([ + (r) => OrderingTerm.asc(r.createdAt), + (r) => OrderingTerm.asc(r.id), + ])) + .get(); + + Future clientCertificate(int id) => + (select(clientCertificates)..where((r) => r.id.equals(id))).getSingleOrNull(); + + Future addClientCertificate(String name, String certPem) => + into(clientCertificates) + .insert(ClientCertificatesCompanion.insert(name: name, certPem: certPem)); + + Future renameClientCertificate(int id, String name) => + (update(clientCertificates)..where((r) => r.id.equals(id))) + .write(ClientCertificatesCompanion(name: Value(name))); + + /// Removes a certificate together with the bindings that wear it, so no + /// site is left asking for a certificate that no longer exists. + Future deleteClientCertificate(int id) => transaction(() async { + await (delete(siteCertificates)..where((r) => r.certificateId.equals(id))).go(); + await (delete(clientCertificates)..where((r) => r.id.equals(id))).go(); + }); + + /// The certificate bound to one host, or null when the reader has not + /// chosen one for it. + Future bindingFor(String host, int port) async { + final row = await (select(siteCertificates) + ..where((r) => r.host.equals(host) & r.port.equals(port))) + .getSingleOrNull(); + return row?.certificateId; + } + + /// The hosts a certificate is presented to, so the page can say where an + /// identity is worn. + Future> bindingsOf(int certificateId) async { + final rows = await (select(siteCertificates) + ..where((r) => r.certificateId.equals(certificateId)) + ..orderBy([ + (r) => OrderingTerm.asc(r.host), + (r) => OrderingTerm.asc(r.port), + ])) + .get(); + return [for (final row in rows) '${row.host}:${row.port}']; + } + + /// Binds a certificate to a host, replacing whatever was bound before. + Future bindCertificate(String host, int port, int certificateId) => + into(siteCertificates).insert( + SiteCertificatesCompanion.insert( + host: host, + port: port, + certificateId: certificateId, + ), + mode: InsertMode.insertOrReplace); } /// How the posts page is ordered. diff --git a/app/lib/database/database.g.dart b/app/lib/database/database.g.dart index 675dec8..4feecb4 100644 --- a/app/lib/database/database.g.dart +++ b/app/lib/database/database.g.dart @@ -2446,6 +2446,936 @@ class CachedPagesCompanion extends UpdateCompanion { } } +class $KnownHostsTable extends KnownHosts + with TableInfo<$KnownHostsTable, KnownHost> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $KnownHostsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _hostMeta = const VerificationMeta('host'); + @override + late final GeneratedColumn host = GeneratedColumn( + 'host', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _portMeta = const VerificationMeta('port'); + @override + late final GeneratedColumn port = GeneratedColumn( + 'port', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _fingerprintMeta = const VerificationMeta( + 'fingerprint', + ); + @override + late final GeneratedColumn fingerprint = GeneratedColumn( + 'fingerprint', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _expiresMeta = const VerificationMeta( + 'expires', + ); + @override + late final GeneratedColumn expires = GeneratedColumn( + 'expires', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _pinnedAtMeta = const VerificationMeta( + 'pinnedAt', + ); + @override + late final GeneratedColumn pinnedAt = GeneratedColumn( + 'pinned_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + @override + List get $columns => [ + host, + port, + fingerprint, + expires, + pinnedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'known_hosts'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('host')) { + context.handle( + _hostMeta, + host.isAcceptableOrUnknown(data['host']!, _hostMeta), + ); + } else if (isInserting) { + context.missing(_hostMeta); + } + if (data.containsKey('port')) { + context.handle( + _portMeta, + port.isAcceptableOrUnknown(data['port']!, _portMeta), + ); + } else if (isInserting) { + context.missing(_portMeta); + } + if (data.containsKey('fingerprint')) { + context.handle( + _fingerprintMeta, + fingerprint.isAcceptableOrUnknown( + data['fingerprint']!, + _fingerprintMeta, + ), + ); + } else if (isInserting) { + context.missing(_fingerprintMeta); + } + if (data.containsKey('expires')) { + context.handle( + _expiresMeta, + expires.isAcceptableOrUnknown(data['expires']!, _expiresMeta), + ); + } else if (isInserting) { + context.missing(_expiresMeta); + } + if (data.containsKey('pinned_at')) { + context.handle( + _pinnedAtMeta, + pinnedAt.isAcceptableOrUnknown(data['pinned_at']!, _pinnedAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {host, port}; + @override + KnownHost map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return KnownHost( + host: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}host'], + )!, + port: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}port'], + )!, + fingerprint: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}fingerprint'], + )!, + expires: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}expires'], + )!, + pinnedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}pinned_at'], + )!, + ); + } + + @override + $KnownHostsTable createAlias(String alias) { + return $KnownHostsTable(attachedDatabase, alias); + } +} + +class KnownHost extends DataClass implements Insertable { + final String host; + final int port; + final String fingerprint; + final DateTime expires; + final DateTime pinnedAt; + const KnownHost({ + required this.host, + required this.port, + required this.fingerprint, + required this.expires, + required this.pinnedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['host'] = Variable(host); + map['port'] = Variable(port); + map['fingerprint'] = Variable(fingerprint); + map['expires'] = Variable(expires); + map['pinned_at'] = Variable(pinnedAt); + return map; + } + + KnownHostsCompanion toCompanion(bool nullToAbsent) { + return KnownHostsCompanion( + host: Value(host), + port: Value(port), + fingerprint: Value(fingerprint), + expires: Value(expires), + pinnedAt: Value(pinnedAt), + ); + } + + factory KnownHost.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return KnownHost( + host: serializer.fromJson(json['host']), + port: serializer.fromJson(json['port']), + fingerprint: serializer.fromJson(json['fingerprint']), + expires: serializer.fromJson(json['expires']), + pinnedAt: serializer.fromJson(json['pinnedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'host': serializer.toJson(host), + 'port': serializer.toJson(port), + 'fingerprint': serializer.toJson(fingerprint), + 'expires': serializer.toJson(expires), + 'pinnedAt': serializer.toJson(pinnedAt), + }; + } + + KnownHost copyWith({ + String? host, + int? port, + String? fingerprint, + DateTime? expires, + DateTime? pinnedAt, + }) => KnownHost( + host: host ?? this.host, + port: port ?? this.port, + fingerprint: fingerprint ?? this.fingerprint, + expires: expires ?? this.expires, + pinnedAt: pinnedAt ?? this.pinnedAt, + ); + KnownHost copyWithCompanion(KnownHostsCompanion data) { + return KnownHost( + host: data.host.present ? data.host.value : this.host, + port: data.port.present ? data.port.value : this.port, + fingerprint: data.fingerprint.present + ? data.fingerprint.value + : this.fingerprint, + expires: data.expires.present ? data.expires.value : this.expires, + pinnedAt: data.pinnedAt.present ? data.pinnedAt.value : this.pinnedAt, + ); + } + + @override + String toString() { + return (StringBuffer('KnownHost(') + ..write('host: $host, ') + ..write('port: $port, ') + ..write('fingerprint: $fingerprint, ') + ..write('expires: $expires, ') + ..write('pinnedAt: $pinnedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(host, port, fingerprint, expires, pinnedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is KnownHost && + other.host == this.host && + other.port == this.port && + other.fingerprint == this.fingerprint && + other.expires == this.expires && + other.pinnedAt == this.pinnedAt); +} + +class KnownHostsCompanion extends UpdateCompanion { + final Value host; + final Value port; + final Value fingerprint; + final Value expires; + final Value pinnedAt; + final Value rowid; + const KnownHostsCompanion({ + this.host = const Value.absent(), + this.port = const Value.absent(), + this.fingerprint = const Value.absent(), + this.expires = const Value.absent(), + this.pinnedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + KnownHostsCompanion.insert({ + required String host, + required int port, + required String fingerprint, + required DateTime expires, + this.pinnedAt = const Value.absent(), + this.rowid = const Value.absent(), + }) : host = Value(host), + port = Value(port), + fingerprint = Value(fingerprint), + expires = Value(expires); + static Insertable custom({ + Expression? host, + Expression? port, + Expression? fingerprint, + Expression? expires, + Expression? pinnedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (host != null) 'host': host, + if (port != null) 'port': port, + if (fingerprint != null) 'fingerprint': fingerprint, + if (expires != null) 'expires': expires, + if (pinnedAt != null) 'pinned_at': pinnedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + KnownHostsCompanion copyWith({ + Value? host, + Value? port, + Value? fingerprint, + Value? expires, + Value? pinnedAt, + Value? rowid, + }) { + return KnownHostsCompanion( + host: host ?? this.host, + port: port ?? this.port, + fingerprint: fingerprint ?? this.fingerprint, + expires: expires ?? this.expires, + pinnedAt: pinnedAt ?? this.pinnedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (host.present) { + map['host'] = Variable(host.value); + } + if (port.present) { + map['port'] = Variable(port.value); + } + if (fingerprint.present) { + map['fingerprint'] = Variable(fingerprint.value); + } + if (expires.present) { + map['expires'] = Variable(expires.value); + } + if (pinnedAt.present) { + map['pinned_at'] = Variable(pinnedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('KnownHostsCompanion(') + ..write('host: $host, ') + ..write('port: $port, ') + ..write('fingerprint: $fingerprint, ') + ..write('expires: $expires, ') + ..write('pinnedAt: $pinnedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $ClientCertificatesTable extends ClientCertificates + with TableInfo<$ClientCertificatesTable, ClientCertificate> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ClientCertificatesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _certPemMeta = const VerificationMeta( + 'certPem', + ); + @override + late final GeneratedColumn certPem = GeneratedColumn( + 'cert_pem', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + @override + List get $columns => [id, name, certPem, createdAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'client_certificates'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), + ); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('cert_pem')) { + context.handle( + _certPemMeta, + certPem.isAcceptableOrUnknown(data['cert_pem']!, _certPemMeta), + ); + } else if (isInserting) { + context.missing(_certPemMeta); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ClientCertificate map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ClientCertificate( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + certPem: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}cert_pem'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + $ClientCertificatesTable createAlias(String alias) { + return $ClientCertificatesTable(attachedDatabase, alias); + } +} + +class ClientCertificate extends DataClass + implements Insertable { + final int id; + final String name; + final String certPem; + final DateTime createdAt; + const ClientCertificate({ + required this.id, + required this.name, + required this.certPem, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['cert_pem'] = Variable(certPem); + map['created_at'] = Variable(createdAt); + return map; + } + + ClientCertificatesCompanion toCompanion(bool nullToAbsent) { + return ClientCertificatesCompanion( + id: Value(id), + name: Value(name), + certPem: Value(certPem), + createdAt: Value(createdAt), + ); + } + + factory ClientCertificate.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ClientCertificate( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + certPem: serializer.fromJson(json['certPem']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'certPem': serializer.toJson(certPem), + 'createdAt': serializer.toJson(createdAt), + }; + } + + ClientCertificate copyWith({ + int? id, + String? name, + String? certPem, + DateTime? createdAt, + }) => ClientCertificate( + id: id ?? this.id, + name: name ?? this.name, + certPem: certPem ?? this.certPem, + createdAt: createdAt ?? this.createdAt, + ); + ClientCertificate copyWithCompanion(ClientCertificatesCompanion data) { + return ClientCertificate( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + certPem: data.certPem.present ? data.certPem.value : this.certPem, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('ClientCertificate(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('certPem: $certPem, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, name, certPem, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ClientCertificate && + other.id == this.id && + other.name == this.name && + other.certPem == this.certPem && + other.createdAt == this.createdAt); +} + +class ClientCertificatesCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value certPem; + final Value createdAt; + const ClientCertificatesCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.certPem = const Value.absent(), + this.createdAt = const Value.absent(), + }); + ClientCertificatesCompanion.insert({ + this.id = const Value.absent(), + required String name, + required String certPem, + this.createdAt = const Value.absent(), + }) : name = Value(name), + certPem = Value(certPem); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? certPem, + Expression? createdAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (certPem != null) 'cert_pem': certPem, + if (createdAt != null) 'created_at': createdAt, + }); + } + + ClientCertificatesCompanion copyWith({ + Value? id, + Value? name, + Value? certPem, + Value? createdAt, + }) { + return ClientCertificatesCompanion( + id: id ?? this.id, + name: name ?? this.name, + certPem: certPem ?? this.certPem, + createdAt: createdAt ?? this.createdAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (certPem.present) { + map['cert_pem'] = Variable(certPem.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ClientCertificatesCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('certPem: $certPem, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } +} + +class $SiteCertificatesTable extends SiteCertificates + with TableInfo<$SiteCertificatesTable, SiteCertificate> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $SiteCertificatesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _hostMeta = const VerificationMeta('host'); + @override + late final GeneratedColumn host = GeneratedColumn( + 'host', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _portMeta = const VerificationMeta('port'); + @override + late final GeneratedColumn port = GeneratedColumn( + 'port', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _certificateIdMeta = const VerificationMeta( + 'certificateId', + ); + @override + late final GeneratedColumn certificateId = GeneratedColumn( + 'certificate_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [host, port, certificateId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'site_certificates'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('host')) { + context.handle( + _hostMeta, + host.isAcceptableOrUnknown(data['host']!, _hostMeta), + ); + } else if (isInserting) { + context.missing(_hostMeta); + } + if (data.containsKey('port')) { + context.handle( + _portMeta, + port.isAcceptableOrUnknown(data['port']!, _portMeta), + ); + } else if (isInserting) { + context.missing(_portMeta); + } + if (data.containsKey('certificate_id')) { + context.handle( + _certificateIdMeta, + certificateId.isAcceptableOrUnknown( + data['certificate_id']!, + _certificateIdMeta, + ), + ); + } else if (isInserting) { + context.missing(_certificateIdMeta); + } + return context; + } + + @override + Set get $primaryKey => {host, port}; + @override + SiteCertificate map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SiteCertificate( + host: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}host'], + )!, + port: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}port'], + )!, + certificateId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}certificate_id'], + )!, + ); + } + + @override + $SiteCertificatesTable createAlias(String alias) { + return $SiteCertificatesTable(attachedDatabase, alias); + } +} + +class SiteCertificate extends DataClass implements Insertable { + final String host; + final int port; + final int certificateId; + const SiteCertificate({ + required this.host, + required this.port, + required this.certificateId, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['host'] = Variable(host); + map['port'] = Variable(port); + map['certificate_id'] = Variable(certificateId); + return map; + } + + SiteCertificatesCompanion toCompanion(bool nullToAbsent) { + return SiteCertificatesCompanion( + host: Value(host), + port: Value(port), + certificateId: Value(certificateId), + ); + } + + factory SiteCertificate.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return SiteCertificate( + host: serializer.fromJson(json['host']), + port: serializer.fromJson(json['port']), + certificateId: serializer.fromJson(json['certificateId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'host': serializer.toJson(host), + 'port': serializer.toJson(port), + 'certificateId': serializer.toJson(certificateId), + }; + } + + SiteCertificate copyWith({String? host, int? port, int? certificateId}) => + SiteCertificate( + host: host ?? this.host, + port: port ?? this.port, + certificateId: certificateId ?? this.certificateId, + ); + SiteCertificate copyWithCompanion(SiteCertificatesCompanion data) { + return SiteCertificate( + host: data.host.present ? data.host.value : this.host, + port: data.port.present ? data.port.value : this.port, + certificateId: data.certificateId.present + ? data.certificateId.value + : this.certificateId, + ); + } + + @override + String toString() { + return (StringBuffer('SiteCertificate(') + ..write('host: $host, ') + ..write('port: $port, ') + ..write('certificateId: $certificateId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(host, port, certificateId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SiteCertificate && + other.host == this.host && + other.port == this.port && + other.certificateId == this.certificateId); +} + +class SiteCertificatesCompanion extends UpdateCompanion { + final Value host; + final Value port; + final Value certificateId; + final Value rowid; + const SiteCertificatesCompanion({ + this.host = const Value.absent(), + this.port = const Value.absent(), + this.certificateId = const Value.absent(), + this.rowid = const Value.absent(), + }); + SiteCertificatesCompanion.insert({ + required String host, + required int port, + required int certificateId, + this.rowid = const Value.absent(), + }) : host = Value(host), + port = Value(port), + certificateId = Value(certificateId); + static Insertable custom({ + Expression? host, + Expression? port, + Expression? certificateId, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (host != null) 'host': host, + if (port != null) 'port': port, + if (certificateId != null) 'certificate_id': certificateId, + if (rowid != null) 'rowid': rowid, + }); + } + + SiteCertificatesCompanion copyWith({ + Value? host, + Value? port, + Value? certificateId, + Value? rowid, + }) { + return SiteCertificatesCompanion( + host: host ?? this.host, + port: port ?? this.port, + certificateId: certificateId ?? this.certificateId, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (host.present) { + map['host'] = Variable(host.value); + } + if (port.present) { + map['port'] = Variable(port.value); + } + if (certificateId.present) { + map['certificate_id'] = Variable(certificateId.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SiteCertificatesCompanion(') + ..write('host: $host, ') + ..write('port: $port, ') + ..write('certificateId: $certificateId, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + abstract class _$MewsDatabase extends GeneratedDatabase { _$MewsDatabase(QueryExecutor e) : super(e); $MewsDatabaseManager get managers => $MewsDatabaseManager(this); @@ -2456,6 +3386,12 @@ abstract class _$MewsDatabase extends GeneratedDatabase { late final $ItemTagsTable itemTags = $ItemTagsTable(this); late final $ReaderSettingsTable readerSettings = $ReaderSettingsTable(this); late final $CachedPagesTable cachedPages = $CachedPagesTable(this); + late final $KnownHostsTable knownHosts = $KnownHostsTable(this); + late final $ClientCertificatesTable clientCertificates = + $ClientCertificatesTable(this); + late final $SiteCertificatesTable siteCertificates = $SiteCertificatesTable( + this, + ); late final Index historyItemsUrl = Index( 'history_items_url', 'CREATE INDEX history_items_url ON history_items (url)', @@ -2472,6 +3408,9 @@ abstract class _$MewsDatabase extends GeneratedDatabase { itemTags, readerSettings, cachedPages, + knownHosts, + clientCertificates, + siteCertificates, historyItemsUrl, ]; } @@ -3837,6 +4776,563 @@ typedef $$CachedPagesTableProcessedTableManager = CachedPage, PrefetchHooks Function() >; +typedef $$KnownHostsTableCreateCompanionBuilder = + KnownHostsCompanion Function({ + required String host, + required int port, + required String fingerprint, + required DateTime expires, + Value pinnedAt, + Value rowid, + }); +typedef $$KnownHostsTableUpdateCompanionBuilder = + KnownHostsCompanion Function({ + Value host, + Value port, + Value fingerprint, + Value expires, + Value pinnedAt, + Value rowid, + }); + +class $$KnownHostsTableFilterComposer + extends Composer<_$MewsDatabase, $KnownHostsTable> { + $$KnownHostsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get host => $composableBuilder( + column: $table.host, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get port => $composableBuilder( + column: $table.port, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get fingerprint => $composableBuilder( + column: $table.fingerprint, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get expires => $composableBuilder( + column: $table.expires, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get pinnedAt => $composableBuilder( + column: $table.pinnedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$KnownHostsTableOrderingComposer + extends Composer<_$MewsDatabase, $KnownHostsTable> { + $$KnownHostsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get host => $composableBuilder( + column: $table.host, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get port => $composableBuilder( + column: $table.port, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get fingerprint => $composableBuilder( + column: $table.fingerprint, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get expires => $composableBuilder( + column: $table.expires, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get pinnedAt => $composableBuilder( + column: $table.pinnedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$KnownHostsTableAnnotationComposer + extends Composer<_$MewsDatabase, $KnownHostsTable> { + $$KnownHostsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get host => + $composableBuilder(column: $table.host, builder: (column) => column); + + GeneratedColumn get port => + $composableBuilder(column: $table.port, builder: (column) => column); + + GeneratedColumn get fingerprint => $composableBuilder( + column: $table.fingerprint, + builder: (column) => column, + ); + + GeneratedColumn get expires => + $composableBuilder(column: $table.expires, builder: (column) => column); + + GeneratedColumn get pinnedAt => + $composableBuilder(column: $table.pinnedAt, builder: (column) => column); +} + +class $$KnownHostsTableTableManager + extends + RootTableManager< + _$MewsDatabase, + $KnownHostsTable, + KnownHost, + $$KnownHostsTableFilterComposer, + $$KnownHostsTableOrderingComposer, + $$KnownHostsTableAnnotationComposer, + $$KnownHostsTableCreateCompanionBuilder, + $$KnownHostsTableUpdateCompanionBuilder, + ( + KnownHost, + BaseReferences<_$MewsDatabase, $KnownHostsTable, KnownHost>, + ), + KnownHost, + PrefetchHooks Function() + > { + $$KnownHostsTableTableManager(_$MewsDatabase db, $KnownHostsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$KnownHostsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$KnownHostsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$KnownHostsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value host = const Value.absent(), + Value port = const Value.absent(), + Value fingerprint = const Value.absent(), + Value expires = const Value.absent(), + Value pinnedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => KnownHostsCompanion( + host: host, + port: port, + fingerprint: fingerprint, + expires: expires, + pinnedAt: pinnedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String host, + required int port, + required String fingerprint, + required DateTime expires, + Value pinnedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => KnownHostsCompanion.insert( + host: host, + port: port, + fingerprint: fingerprint, + expires: expires, + pinnedAt: pinnedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$KnownHostsTableProcessedTableManager = + ProcessedTableManager< + _$MewsDatabase, + $KnownHostsTable, + KnownHost, + $$KnownHostsTableFilterComposer, + $$KnownHostsTableOrderingComposer, + $$KnownHostsTableAnnotationComposer, + $$KnownHostsTableCreateCompanionBuilder, + $$KnownHostsTableUpdateCompanionBuilder, + (KnownHost, BaseReferences<_$MewsDatabase, $KnownHostsTable, KnownHost>), + KnownHost, + PrefetchHooks Function() + >; +typedef $$ClientCertificatesTableCreateCompanionBuilder = + ClientCertificatesCompanion Function({ + Value id, + required String name, + required String certPem, + Value createdAt, + }); +typedef $$ClientCertificatesTableUpdateCompanionBuilder = + ClientCertificatesCompanion Function({ + Value id, + Value name, + Value certPem, + Value createdAt, + }); + +class $$ClientCertificatesTableFilterComposer + extends Composer<_$MewsDatabase, $ClientCertificatesTable> { + $$ClientCertificatesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get certPem => $composableBuilder( + column: $table.certPem, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$ClientCertificatesTableOrderingComposer + extends Composer<_$MewsDatabase, $ClientCertificatesTable> { + $$ClientCertificatesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get certPem => $composableBuilder( + column: $table.certPem, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ClientCertificatesTableAnnotationComposer + extends Composer<_$MewsDatabase, $ClientCertificatesTable> { + $$ClientCertificatesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get certPem => + $composableBuilder(column: $table.certPem, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); +} + +class $$ClientCertificatesTableTableManager + extends + RootTableManager< + _$MewsDatabase, + $ClientCertificatesTable, + ClientCertificate, + $$ClientCertificatesTableFilterComposer, + $$ClientCertificatesTableOrderingComposer, + $$ClientCertificatesTableAnnotationComposer, + $$ClientCertificatesTableCreateCompanionBuilder, + $$ClientCertificatesTableUpdateCompanionBuilder, + ( + ClientCertificate, + BaseReferences< + _$MewsDatabase, + $ClientCertificatesTable, + ClientCertificate + >, + ), + ClientCertificate, + PrefetchHooks Function() + > { + $$ClientCertificatesTableTableManager( + _$MewsDatabase db, + $ClientCertificatesTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ClientCertificatesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ClientCertificatesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ClientCertificatesTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value name = const Value.absent(), + Value certPem = const Value.absent(), + Value createdAt = const Value.absent(), + }) => ClientCertificatesCompanion( + id: id, + name: name, + certPem: certPem, + createdAt: createdAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String name, + required String certPem, + Value createdAt = const Value.absent(), + }) => ClientCertificatesCompanion.insert( + id: id, + name: name, + certPem: certPem, + createdAt: createdAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$ClientCertificatesTableProcessedTableManager = + ProcessedTableManager< + _$MewsDatabase, + $ClientCertificatesTable, + ClientCertificate, + $$ClientCertificatesTableFilterComposer, + $$ClientCertificatesTableOrderingComposer, + $$ClientCertificatesTableAnnotationComposer, + $$ClientCertificatesTableCreateCompanionBuilder, + $$ClientCertificatesTableUpdateCompanionBuilder, + ( + ClientCertificate, + BaseReferences< + _$MewsDatabase, + $ClientCertificatesTable, + ClientCertificate + >, + ), + ClientCertificate, + PrefetchHooks Function() + >; +typedef $$SiteCertificatesTableCreateCompanionBuilder = + SiteCertificatesCompanion Function({ + required String host, + required int port, + required int certificateId, + Value rowid, + }); +typedef $$SiteCertificatesTableUpdateCompanionBuilder = + SiteCertificatesCompanion Function({ + Value host, + Value port, + Value certificateId, + Value rowid, + }); + +class $$SiteCertificatesTableFilterComposer + extends Composer<_$MewsDatabase, $SiteCertificatesTable> { + $$SiteCertificatesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get host => $composableBuilder( + column: $table.host, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get port => $composableBuilder( + column: $table.port, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get certificateId => $composableBuilder( + column: $table.certificateId, + builder: (column) => ColumnFilters(column), + ); +} + +class $$SiteCertificatesTableOrderingComposer + extends Composer<_$MewsDatabase, $SiteCertificatesTable> { + $$SiteCertificatesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get host => $composableBuilder( + column: $table.host, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get port => $composableBuilder( + column: $table.port, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get certificateId => $composableBuilder( + column: $table.certificateId, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$SiteCertificatesTableAnnotationComposer + extends Composer<_$MewsDatabase, $SiteCertificatesTable> { + $$SiteCertificatesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get host => + $composableBuilder(column: $table.host, builder: (column) => column); + + GeneratedColumn get port => + $composableBuilder(column: $table.port, builder: (column) => column); + + GeneratedColumn get certificateId => $composableBuilder( + column: $table.certificateId, + builder: (column) => column, + ); +} + +class $$SiteCertificatesTableTableManager + extends + RootTableManager< + _$MewsDatabase, + $SiteCertificatesTable, + SiteCertificate, + $$SiteCertificatesTableFilterComposer, + $$SiteCertificatesTableOrderingComposer, + $$SiteCertificatesTableAnnotationComposer, + $$SiteCertificatesTableCreateCompanionBuilder, + $$SiteCertificatesTableUpdateCompanionBuilder, + ( + SiteCertificate, + BaseReferences< + _$MewsDatabase, + $SiteCertificatesTable, + SiteCertificate + >, + ), + SiteCertificate, + PrefetchHooks Function() + > { + $$SiteCertificatesTableTableManager( + _$MewsDatabase db, + $SiteCertificatesTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$SiteCertificatesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$SiteCertificatesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$SiteCertificatesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value host = const Value.absent(), + Value port = const Value.absent(), + Value certificateId = const Value.absent(), + Value rowid = const Value.absent(), + }) => SiteCertificatesCompanion( + host: host, + port: port, + certificateId: certificateId, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String host, + required int port, + required int certificateId, + Value rowid = const Value.absent(), + }) => SiteCertificatesCompanion.insert( + host: host, + port: port, + certificateId: certificateId, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$SiteCertificatesTableProcessedTableManager = + ProcessedTableManager< + _$MewsDatabase, + $SiteCertificatesTable, + SiteCertificate, + $$SiteCertificatesTableFilterComposer, + $$SiteCertificatesTableOrderingComposer, + $$SiteCertificatesTableAnnotationComposer, + $$SiteCertificatesTableCreateCompanionBuilder, + $$SiteCertificatesTableUpdateCompanionBuilder, + ( + SiteCertificate, + BaseReferences<_$MewsDatabase, $SiteCertificatesTable, SiteCertificate>, + ), + SiteCertificate, + PrefetchHooks Function() + >; class $MewsDatabaseManager { final _$MewsDatabase _db; @@ -3855,4 +5351,10 @@ class $MewsDatabaseManager { $$ReaderSettingsTableTableManager(_db, _db.readerSettings); $$CachedPagesTableTableManager get cachedPages => $$CachedPagesTableTableManager(_db, _db.cachedPages); + $$KnownHostsTableTableManager get knownHosts => + $$KnownHostsTableTableManager(_db, _db.knownHosts); + $$ClientCertificatesTableTableManager get clientCertificates => + $$ClientCertificatesTableTableManager(_db, _db.clientCertificates); + $$SiteCertificatesTableTableManager get siteCertificates => + $$SiteCertificatesTableTableManager(_db, _db.siteCertificates); } diff --git a/app/lib/mews/gemtext.dart b/app/lib/mews/gemtext.dart new file mode 100644 index 0000000..0abaf3f --- /dev/null +++ b/app/lib/mews/gemtext.dart @@ -0,0 +1,154 @@ +/// Converts gemtext, the format Gemini and Spartan pages are written in, into +/// the document model (document.dart), so protocol pages render through the +/// same reader presentation as Mews pages. Gemtext declares neither structure +/// nor metadata: there is no inline markup, no head, no title, so the first +/// level-1 heading stands in for a title and the reader presentation decides +/// everything else. +/// +/// A link line whose label begins with a date is a dated entry (mews-spec +/// 6.1), which keeps subscriptions working on protocol sites. +library; + +import 'dart:convert'; + +import 'document.dart'; + +/// The conformance marker gemtext pages carry in place of the Mews one, so the +/// renderer applies the reader presentation instead of falling back to plain. +const gemtextSpecVersion = 'gemtext'; + +final _entryDatePattern = RegExp(r'^(\d{4}-\d{2}-\d{2}) (.+)$'); + +MewsPage parseGemtextPage({required String source, required String url}) { + final blocks = []; + final entries = []; + String? title; + + var inPre = false; + final pre = StringBuffer(); + final quote = StringBuffer(); + final linkItems = []; + + void closePre() { + // One trailing newline rides with the last line; the fence is not content. + blocks.add(PreBlock(pre.toString().replaceFirst(RegExp(r'\n$'), ''))); + pre.clear(); + } + + // Consecutive lines of one kind are gathered until a line of another kind + // ends them. + void closeQuote() { + if (quote.isEmpty) return; + final lines = quote.toString().split('\n'); + blocks.add(QuoteBlock([ + for (final line in lines) + if (line.trim().isNotEmpty) ParagraphBlock([MewsText(line.trim())]), + ])); + quote.clear(); + } + + void closeLinks() { + if (linkItems.isEmpty) return; + blocks.add(ListBlock(ordered: false, start: 1, items: List.of(linkItems))); + linkItems.clear(); + } + + for (final line in const LineSplitter().convert(source)) { + if (inPre) { + if (line.startsWith('```')) { + closePre(); + inPre = false; + } else { + pre.writeln(line); + } + continue; + } + if (line.startsWith('```')) { + closeQuote(); + closeLinks(); + inPre = true; + continue; + } + if (line.startsWith('=>')) { + closeQuote(); + final parts = line.substring(2).trim().split(RegExp(r'\s+')); + if (parts.isEmpty || parts.first.isEmpty) continue; + final href = Uri.parse(url).resolve(parts.first).toString(); + final label = parts.skip(1).join(' ').trim(); + final text = label.isEmpty ? parts.first : label; + final match = _entryDatePattern.firstMatch(text); + if (match != null) { + final date = DateTime.tryParse(match.group(1)!); + if (date != null) { + entries.add(MewsEntry(url: href, date: date, title: match.group(2)!.trim())); + } + } + linkItems.add(MewsListItem([ParagraphBlock([MewsLink(href, [MewsText(text)])])])); + continue; + } + closeLinks(); + if (line.startsWith('###') && !line.startsWith('####')) { + closeQuote(); + final text = line.substring(3).trim(); + blocks.add(HeadingBlock(3, [MewsText(text)])); + } else if (line.startsWith('##') && !line.startsWith('###')) { + closeQuote(); + final text = line.substring(2).trim(); + blocks.add(HeadingBlock(2, [MewsText(text)])); + } else if (line.startsWith('#') && !line.startsWith('##')) { + closeQuote(); + final text = line.substring(1).trim(); + final heading = HeadingBlock(1, [MewsText(text)]); + title ??= text; + blocks.add(heading); + } else if (line.startsWith('* ')) { + closeQuote(); + _listOf(blocks).items.add(MewsListItem([ParagraphBlock([MewsText(line.substring(2).trim())])])); + } else if (line.startsWith('>')) { + quote.writeln(line.substring(1).trim()); + } else if (line.trim().isNotEmpty) { + closeQuote(); + blocks.add(ParagraphBlock([MewsText(line.trim())])); + } else { + closeQuote(); + } + } + if (inPre) closePre(); + closeQuote(); + closeLinks(); + + entries.sort((a, b) => b.date.compareTo(a.date)); + + final text = source; + return MewsPage( + url: url, + specVersion: gemtextSpecVersion, + title: title, + lang: _langOf(text), + direction: _isRtl(text) ? TextDirection.rtl : TextDirection.ltr, + variant: MewsVariant.plain, + entries: entries, + body: blocks, + ); +} + +/// Appends a list block to build on, reusing the last one while consecutive +/// items keep coming. +ListBlock _listOf(List blocks) { + if (blocks.isNotEmpty && blocks.last is ListBlock && !(blocks.last as ListBlock).ordered) { + return blocks.last as ListBlock; + } + final fresh = ListBlock(ordered: false, start: 1, items: []); + blocks.add(fresh); + return fresh; +} + +final _rtlPattern = RegExp(r'[\u0590-\u08FF]'); + +bool _isRtl(String text) => _rtlPattern.hasMatch(text); + +String _langOf(String text) { + if (RegExp(r'[\u0590-\u05FF]').hasMatch(text)) return 'he'; + if (RegExp(r'[\u0600-\u06FF]').hasMatch(text)) return 'ar'; + return 'en'; +} diff --git a/app/lib/presentation/app.dart b/app/lib/presentation/app.dart index 2ef33e6..ee67e7e 100644 --- a/app/lib/presentation/app.dart +++ b/app/lib/presentation/app.dart @@ -3,8 +3,12 @@ import 'dart:async' show unawaited; import 'dart:ui' show PlatformDispatcher; import 'package:actors/actors.dart'; +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; import 'package:signals/signals_flutter.dart' show SignalBuilder; import '../application/actors/fetch_actor.dart'; @@ -13,8 +17,9 @@ import '../application/controllers/browser_controller.dart'; import '../application/controllers/reader_settings_controller.dart'; import '../application/controllers/subscriptions_controller.dart'; import '../application/fonts.dart'; -import '../application/internal_actions.dart'; +import '../application/internal_actions.dart' show InternalActions, PickedPem; import '../application/internal_pages.dart'; +import '../application/key_store.dart'; import '../database/database.dart'; import '../l10n/locales.dart'; import '../l10n/strings.dart'; @@ -29,12 +34,19 @@ class AppDependencies { MewsDatabase? database, Actor? fetch, Strings? strings, + KeyStore? keys, + Future Function(String title)? pickPemFile, }) : db = database ?? MewsDatabase(), fetch = fetch ?? Actor.create(FetchHandler.new), strings = strings ?? Strings() { settings = ReaderSettingsController(db); bookmarks = BookmarksController(db); subscriptions = SubscriptionsController(db: db, fetch: this.fetch); + this.keys = keys ?? + KeyStore(() async { + final support = await getApplicationSupportDirectory(); + return Directory(p.join(support.path, 'keys')); + }); internalPages = InternalPages( db: db, settings: settings, @@ -46,10 +58,14 @@ class AppDependencies { settings: settings, bookmarks: bookmarks, subscriptions: subscriptions, + keys: this.keys, + strings: this.strings, + pickPemFile: pickPemFile, ); browser = BrowserController( db: db, fetch: this.fetch, + keys: this.keys, internalPages: internalPages, internalActions: internalActions, ); @@ -63,6 +79,7 @@ class AppDependencies { final MewsDatabase db; final Actor fetch; + late final KeyStore keys; late final BrowserController browser; late final ReaderSettingsController settings; late final BookmarksController bookmarks; diff --git a/app/lib/presentation/reader/address_bar.dart b/app/lib/presentation/reader/address_bar.dart index 8b2f430..d127f33 100644 --- a/app/lib/presentation/reader/address_bar.dart +++ b/app/lib/presentation/reader/address_bar.dart @@ -1,4 +1,5 @@ -import 'package:flutter/foundation.dart' show defaultTargetPlatform, TargetPlatform; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, TargetPlatform; import 'package:flutter/material.dart'; import 'package:signals/signals_flutter.dart' show SignalBuilder; @@ -28,8 +29,10 @@ class _AddressBarState extends State { if (_focus.hasFocus != _focused) { setState(() => _focused = _focus.hasFocus); if (_focused) { - _controller.selection = - TextSelection(baseOffset: 0, extentOffset: _controller.text.length); + _controller.selection = TextSelection( + baseOffset: 0, + extentOffset: _controller.text.length, + ); } } }); @@ -47,117 +50,126 @@ class _AddressBarState extends State { final deps = AppScope.of(context).deps; final strings = AppScope.of(context).strings; - return SignalBuilder(builder: (context) { - final page = deps.browser.currentPage.value; - final url = page?.url; - if (url != null && !_focused) { - _controller.text = url; - } + return SignalBuilder( + builder: (context) { + final page = deps.browser.currentPage.value; + final url = page?.url; + if (url != null && !_focused) { + _controller.text = url; + } - // Touch devices navigate with swipes; desktops get buttons. - final hasButtons = switch (defaultTargetPlatform) { - TargetPlatform.linux || - TargetPlatform.windows || - TargetPlatform.macOS => true, - _ => false, - }; + // Touch devices navigate with swipes; desktops get buttons. + final hasButtons = switch (defaultTargetPlatform) { + TargetPlatform.linux || + TargetPlatform.windows || + TargetPlatform.macOS => true, + _ => false, + }; - // Only pages with dated entry links or an Atom feed can be subscribed - // to (mews spec, section 6). - final canSubscribe = page != null && - !MewsUrls.isInternal(page.url) && - (page.atomFeedHref != null || page.entries.isNotEmpty); - final canBookmark = page != null && !MewsUrls.isInternal(page.url); + // Only pages with dated entry links or an Atom feed can be subscribed + // to (mews spec, section 6). + final canSubscribe = + page != null && + !MewsUrls.isInternal(page.url) && + (page.atomFeedHref != null || page.entries.isNotEmpty); + final canBookmark = page != null && !MewsUrls.isInternal(page.url); - // One button, two layouts: on pointer platforms it sits beside the - // back and forward buttons. - final hubButton = IconButton( - icon: const Icon(Icons.menu), - tooltip: strings.t('reader-open-hub'), - onPressed: () => deps.browser.open(MewsUrls.hub), - ); + // One button, two layouts: on pointer platforms it sits beside the + // back and forward buttons. + final hubButton = IconButton( + icon: const Icon(Icons.menu), + tooltip: strings.t('reader-open-hub'), + onPressed: () => deps.browser.open(MewsUrls.hub), + ); - return Padding( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), - child: TextField( - controller: _controller, - focusNode: _focus, - autofocus: false, - keyboardType: TextInputType.url, - textInputAction: TextInputAction.go, - autocorrect: false, - enableSuggestions: false, - onSubmitted: (value) { - _focus.unfocus(); - deps.browser.open(value); - }, - decoration: InputDecoration( - hintText: strings.t('reader-address-placeholder'), - labelText: strings.t('reader-address-label'), - isDense: true, - border: const OutlineInputBorder(), - prefixIcon: hasButtons - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - hubButton, + return Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), + // The label is for screen readers only: the placeholder is what a + // sighted reader sees, and a visible labelText would repeat it. + child: Semantics( + label: strings.t('reader-address-label'), + textField: true, + child: TextField( + controller: _controller, + focusNode: _focus, + autofocus: false, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.go, + autocorrect: false, + enableSuggestions: false, + onSubmitted: (value) { + _focus.unfocus(); + deps.browser.open(value); + }, + decoration: InputDecoration( + hintText: strings.t('reader-address-placeholder'), + isDense: true, + border: const OutlineInputBorder(), + prefixIcon: hasButtons + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + hubButton, + IconButton( + icon: const Icon(Icons.arrow_back), + tooltip: strings.t('reader-back'), + onPressed: deps.browser.canGoBack + ? deps.browser.back + : null, + ), + IconButton( + icon: const Icon(Icons.arrow_forward), + tooltip: strings.t('reader-forward'), + onPressed: deps.browser.canGoForward + ? deps.browser.forward + : null, + ), + ], + ) + : hubButton, + prefixIconConstraints: hasButtons + ? const BoxConstraints(minWidth: 138, minHeight: 48) + : null, + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: strings.t('reader-reload'), + onPressed: () => deps.browser.reload(), + ), + if (canSubscribe) IconButton( - icon: const Icon(Icons.arrow_back), - tooltip: strings.t('reader-back'), - onPressed: deps.browser.canGoBack - ? deps.browser.back - : null, + icon: Icon( + deps.subscriptions.isSubscribed(page.url) + ? Icons.notifications_active + : Icons.notifications_outlined, + ), + tooltip: deps.subscriptions.isSubscribed(page.url) + ? strings.t('reader-unsubscribe') + : strings.t('reader-subscribe'), + onPressed: () => deps.subscriptions.toggle(page), ), + if (canBookmark) IconButton( - icon: const Icon(Icons.arrow_forward), - tooltip: strings.t('reader-forward'), - onPressed: deps.browser.canGoForward - ? deps.browser.forward - : null, + icon: Icon( + deps.bookmarks.isBookmarked(page.url) + ? Icons.star + : Icons.star_border, + ), + tooltip: deps.bookmarks.isBookmarked(page.url) + ? strings.t('reader-bookmark-remove') + : strings.t('reader-bookmark-add'), + onPressed: () => + deps.bookmarks.toggle(page.url, page.title), ), - ], - ) - : hubButton, - prefixIconConstraints: hasButtons - ? const BoxConstraints(minWidth: 138, minHeight: 48) - : null, - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.refresh), - tooltip: strings.t('reader-reload'), - onPressed: () => deps.browser.reload(), + ], ), - if (canSubscribe) - IconButton( - icon: Icon( - deps.subscriptions.isSubscribed(page.url) - ? Icons.notifications_active - : Icons.notifications_outlined, - ), - tooltip: deps.subscriptions.isSubscribed(page.url) - ? strings.t('reader-unsubscribe') - : strings.t('reader-subscribe'), - onPressed: () => deps.subscriptions.toggle(page), - ), - if (canBookmark) - IconButton( - icon: Icon( - deps.bookmarks.isBookmarked(page.url) - ? Icons.star - : Icons.star_border, - ), - tooltip: deps.bookmarks.isBookmarked(page.url) - ? strings.t('reader-bookmark-remove') - : strings.t('reader-bookmark-add'), - onPressed: () => deps.bookmarks.toggle(page.url, page.title), - ), - ], + ), ), ), - ), - ); - }); + ); + }, + ); } } diff --git a/app/lib/presentation/reader/mews_page_view.dart b/app/lib/presentation/reader/mews_page_view.dart index f902da5..6a90ee8 100644 --- a/app/lib/presentation/reader/mews_page_view.dart +++ b/app/lib/presentation/reader/mews_page_view.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'dart:ui' as ui show TextDirection; import 'package:flutter/gestures.dart'; @@ -20,6 +22,7 @@ class MewsPageView extends StatefulWidget { required this.visitedUrls, required this.onLinkTap, required this.onFormSubmit, + this.fetchImageBytes, this.scrollController, this.ownPage = false, super.key, @@ -32,6 +35,10 @@ class MewsPageView extends StatefulWidget { final Set visitedUrls; final void Function(String url) onLinkTap; final void Function(FormBlock form, Map values) onFormSubmit; + + /// The bytes of an image living on a protocol page, where no address exists + /// that the platform could load from. Null when the page holds none. + final Future Function(String url)? fetchImageBytes; final ScrollController? scrollController; /// Whether this is one of the client's own pages. Those are built from the @@ -47,6 +54,10 @@ class MewsPageView extends StatefulWidget { class _MewsPageViewState extends State { final _recognizers = {}; + /// Images already asked for, so a rebuild shows the same one arriving + /// instead of asking again. A failure is forgotten, so trying again works. + final _images = >{}; + @override void didUpdateWidget(MewsPageView oldWidget) { super.didUpdateWidget(oldWidget); @@ -312,13 +323,7 @@ class _MewsPageViewState extends State { ), ImageBlock(:final src, :final alt, :final allowed) => Padding( padding: EdgeInsets.only(bottom: base), - child: allowed - ? Image.network( - src, - fit: BoxFit.scaleDown, - errorBuilder: (_, _, _) => _imageFallback(alt, base, unavailable: true), - ) - : _imageFallback(alt, base), + child: allowed ? _image(src, alt, base) : _imageFallback(alt, base), ), FormBlock() => Padding( padding: EdgeInsets.only(bottom: base), @@ -332,6 +337,40 @@ class _MewsPageViewState extends State { }; } + /// An allowed image, from the platform on the web or from the protocol's + /// own bytes on a capsule. A blocked or unreachable image falls back to its + /// alt text, the way a missing image does. + Widget _image(String src, String alt, double base) { + final scheme = Uri.tryParse(src)?.scheme; + if (scheme != 'gemini' && scheme != 'spartan') { + return Image.network( + src, + fit: BoxFit.scaleDown, + errorBuilder: (_, _, _) => _imageFallback(alt, base, unavailable: true), + ); + } + final loader = widget.fetchImageBytes; + if (loader == null) return _imageFallback(alt, base, unavailable: true); + return FutureBuilder( + future: _images.putIfAbsent(src, () { + final arriving = loader(src); + arriving.then((bytes) { + if (bytes == null) _images.remove(src); + }); + return arriving; + }), + builder: (context, snapshot) => snapshot.connectionState != ConnectionState.done + ? const SizedBox.shrink() + : snapshot.data == null + ? _imageFallback(alt, base, unavailable: true) + : Image.memory( + snapshot.data!, + fit: BoxFit.scaleDown, + errorBuilder: (_, _, _) => _imageFallback(alt, base, unavailable: true), + ), + ); + } + Widget _imageFallback(String alt, double base, {bool unavailable = false}) { final palette = widget.palette; final strings = StringsScope.of(context); diff --git a/app/lib/presentation/reader/reader_screen.dart b/app/lib/presentation/reader/reader_screen.dart index 7a06c62..14beafb 100644 --- a/app/lib/presentation/reader/reader_screen.dart +++ b/app/lib/presentation/reader/reader_screen.dart @@ -3,7 +3,15 @@ import 'package:flutter/rendering.dart' show ScrollDirection; import 'package:signals/signals_flutter.dart' show SignalBuilder; import '../../application/controllers/browser_controller.dart' - show BrokenPage, LoadFailed; + show + BrokenPage, + CertificateChanged, + ClientCertificateRequired, + LoadFailed, + ProtocolFailure, + RedirectLoop, + UnsupportedPage; +import '../../database/database.dart' show ClientCertificate; import '../../application/pages/mews_urls.dart'; import '../../mews/document.dart'; import '../../mews/parser.dart' show mewsPlainText; @@ -137,6 +145,24 @@ class _ReaderScreenState extends State { return switch (error) { BrokenPage(:final source) => _errorPage(context, source), LoadFailed() => _errorPage(context, null), + // A protocol status carries the site's own words for what went + // wrong. + ProtocolFailure(:final message) => + _plainErrorPage(context, 'error-protocol-failed', detail: message), + // A certificate request is a choice, not a failure: the reader + // presents one of their identities, or none. A refused or invalid + // certificate is a failure the reader cannot fix by choosing again. + ClientCertificateRequired(:final status, :final meta) => status == 60 + ? _identityPickerPage(context, meta) + : _plainErrorPage( + context, + status == 61 + ? 'error-certificate-not-authorized' + : 'error-certificate-invalid', + detail: meta), + RedirectLoop() => _plainErrorPage(context, 'error-redirect-loop'), + UnsupportedPage() => _plainErrorPage(context, 'error-unsupported-type'), + CertificateChanged() => _certificateErrorPage(context), null => _emptyPage(context), }; }); @@ -168,6 +194,7 @@ class _ReaderScreenState extends State { onLinkTap: (url) => deps.browser.open(url), onFormSubmit: (form, values) => deps.browser.submitForm(form.action, form.method, values), + fetchImageBytes: deps.browser.fetchImageBytes, scrollController: _scroll, ); }); @@ -204,6 +231,111 @@ class _ReaderScreenState extends State { return const SizedBox.expand(); } + /// An error page that says [messageId], optionally the site's own words + /// under it, and offers to try again. + Widget _plainErrorPage(BuildContext context, String messageId, {String? detail}) { + final strings = AppScope.of(context).strings; + final deps = AppScope.of(context).deps; + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(strings.t(messageId), textAlign: TextAlign.center), + if (detail != null && detail.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(detail, textAlign: TextAlign.center), + ], + const SizedBox(height: 16), + FilledButton( + onPressed: () => deps.browser.reload(), + child: Text(strings.t('error-try-again')), + ), + ], + ), + ), + ); + } + + /// The choice a certificate request asks for: one of the reader's + /// identities, presented from now on to the site that asked. + Widget _identityPickerPage(BuildContext context, String meta) { + final strings = AppScope.of(context).strings; + final deps = AppScope.of(context).deps; + return FutureBuilder>( + future: deps.db.allClientCertificates(), + builder: (context, snapshot) { + final held = snapshot.data ?? const []; + return Center( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(strings.t('certificates-choose'), textAlign: TextAlign.center), + if (meta.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(meta, textAlign: TextAlign.center), + ], + const SizedBox(height: 16), + if (held.isEmpty) + FilledButton( + onPressed: () => deps.browser.open(MewsUrls.certificates), + child: Text(strings.t('certificates-add')), + ) + else + for (final certificate in held) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: FilledButton.tonal( + onPressed: () => deps.browser.chooseCertificate(certificate.id), + child: Text(certificate.name), + ), + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: () => deps.browser.reload(), + child: Text(strings.t('error-try-again')), + ), + ], + ), + ), + ), + ); + }, + ); + } + + /// A changed certificate is the reader's choice to make or refuse, so the + /// error page carries both: trust what was presented, or try again. + Widget _certificateErrorPage(BuildContext context) { + final strings = AppScope.of(context).strings; + final deps = AppScope.of(context).deps; + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(strings.t('error-certificate-changed'), textAlign: TextAlign.center), + const SizedBox(height: 16), + FilledButton( + onPressed: () => deps.browser.trustPresentedCertificate(), + child: Text(strings.t('error-certificate-trust')), + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: () => deps.browser.reload(), + child: Text(strings.t('error-try-again')), + ), + ], + ), + ), + ); + } + Widget _errorPage(BuildContext context, String? brokenSource) { final strings = AppScope.of(context).strings; final deps = AppScope.of(context).deps; diff --git a/app/lib/protocols/gemini.dart b/app/lib/protocols/gemini.dart new file mode 100644 index 0000000..52c227e --- /dev/null +++ b/app/lib/protocols/gemini.dart @@ -0,0 +1,216 @@ +/// The Gemini protocol client: TLS on port 1965, a request of the absolute +/// URL followed by CRLF, and a response of `` CRLF plus a +/// body on success. Servers present self-signed certificates, so every +/// connection goes through trust-on-first-use pinning (see [tofu.dart]). +/// A client certificate may be offered through the [SecurityContext] the +/// caller passes; it is the caller's decision, never the client's, because a +/// certificate is an identity and the reader decides where to wear it. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'tofu.dart'; + +/// The whole response of one request, redirects already followed. +class GeminiResponse { + const GeminiResponse({ + required this.url, + required this.status, + required this.meta, + this.body, + this.pinUpdates = const [], + }); + + /// The address the content finally came from, after redirects. + final Uri url; + + /// The two-digit status code, e.g. 20, 31, 60. + final int status; + + final String meta; + + /// The response body, present on 2x statuses. + final Uint8List? body; + + /// Certificates to pin: one for each host whose pin was created or renewed + /// on the way. Empty when every hop matched its existing pin. + final List pinUpdates; + + bool get isSuccess => status >= 20 && status < 30; + bool get isRedirect => status >= 30 && status < 40; + bool get wantsClientCertificate => status >= 60 && status < 70; +} + +/// A certificate to pin for one host. +class TofuUpdate { + const TofuUpdate({required this.host, required this.port, required this.pin}); + + final String host; + final int port; + final TofuPin pin; + + @override + bool operator ==(Object other) => + other is TofuUpdate && other.host == host && other.port == port && other.pin == pin; + + @override + int get hashCode => Object.hash(host, port, pin); +} + +/// A connection, protocol or timeout failure. One failure to the reader. +class GeminiException implements Exception { + const GeminiException(this.message); + + final String message; + + @override + String toString() => message; +} + +/// The same address redirected more times than the client follows. +class GeminiTooManyRedirects implements Exception { + const GeminiTooManyRedirects(); +} + +/// The server's certificate is not the one pinned for it, and the pin has not +/// expired. Carries what was presented, so the reader can be offered the +/// choice to trust it. +class GeminiCertificateChanged implements Exception { + const GeminiCertificateChanged({required this.host, required this.port, required this.presented}); + + final String host; + final int port; + final TofuPin presented; +} + +class GeminiClient { + GeminiClient({this.timeout = const Duration(seconds: 20)}); + + static const defaultPort = 1965; + static const maxRedirects = 5; + + /// The largest request the protocol allows, URL plus CRLF. + static const maxRequestBytes = 1024; + + final Duration timeout; + + /// Fetches [url], following 3x redirects up to [maxRedirects] times. + /// + /// [pins] holds the reader's pinned certificates by `host:port`, which + /// every host on the way, redirect targets included, is checked against. + Future fetch(Uri url, + {Map pins = const {}, SecurityContext? clientCert}) async { + final updates = []; + var current = url; + for (var hops = 0;; hops++) { + final response = await _requestOnce(current, pins, clientCert, updates); + if (!response.isRedirect) { + return GeminiResponse( + url: response.url, + status: response.status, + meta: response.meta, + body: response.body, + pinUpdates: updates, + ); + } + if (hops >= maxRedirects) throw const GeminiTooManyRedirects(); + final target = current.resolve(response.meta); + // Only gemini addresses are followed; anything else the redirect points + // to is left for the reader to open as its own navigation. + if (!target.hasScheme || target.scheme != 'gemini' || target.host.isEmpty) { + return response; + } + current = target; + } + } + + Future _requestOnce(Uri url, Map pins, + SecurityContext? clientCert, List updates) async { + final host = url.host; + final port = url.hasPort ? url.port : defaultPort; + final request = utf8.encode('${url.toString()}\r\n'); + if (request.length > maxRequestBytes) { + throw const GeminiException('request longer than the protocol allows'); + } + + final now = DateTime.now(); + TofuPin? presentedByRefusal; + final SecureSocket socket; + try { + socket = await SecureSocket.connect(host, port, + context: clientCert, + timeout: timeout, + onBadCertificate: (certificate) { + final pin = _pinOf(certificate); + final decision = decideTofu(pins['$host:$port'], pin.fingerprint, now); + // Refusing during the handshake, before the request or the + // reader's own certificate is sent, discloses nothing to whoever + // is on the line. + if (decision == TofuDecision.refuse) presentedByRefusal = pin; + return decision != TofuDecision.refuse; + }); + } catch (_) { + if (presentedByRefusal != null) { + throw GeminiCertificateChanged( + host: host, port: port, presented: presentedByRefusal!); + } + throw const GeminiException('could not connect'); + } + + try { + final seen = socket.peerCertificate; + if (seen != null) { + final pin = _pinOf(seen); + final decision = decideTofu(pins['$host:$port'], pin.fingerprint, now); + // A certificate can also clear the handshake on a public chain, so + // the pin is decided here too, not only in the callback above. + if (decision == TofuDecision.refuse) { + throw GeminiCertificateChanged(host: host, port: port, presented: pin); + } + if (decision != TofuDecision.match) { + updates.add(TofuUpdate(host: host, port: port, pin: pin)); + } + } + + socket.add(request); + await socket.flush(); + + final raw = await _readToEnd(socket).timeout(timeout); + final headerEnd = _indexOfCrlf(raw); + if (headerEnd < 2) throw const GeminiException('response without a status line'); + final header = utf8.decode(raw.sublist(0, headerEnd), allowMalformed: true); + if (header.length < 3 || header.codeUnitAt(2) != 0x20) { + throw const GeminiException('malformed status line'); + } + final status = int.tryParse(header.substring(0, 2)); + if (status == null) throw const GeminiException('malformed status line'); + final body = + status >= 20 && status < 30 ? Uint8List.fromList(raw.sublist(headerEnd + 2)) : null; + return GeminiResponse(url: url, status: status, meta: header.substring(3), body: body); + } finally { + socket.destroy(); + } + } + + /// A certificate as a pin: its fingerprint and its own end of validity. + static TofuPin _pinOf(X509Certificate certificate) => + pinOf(certificate.der, expires: certificate.endValidity); + + static Future _readToEnd(Socket socket) async { + final builder = BytesBuilder(copy: false); + await for (final chunk in socket) { + builder.add(chunk); + } + return builder.toBytes(); + } + + static int _indexOfCrlf(Uint8List bytes) { + for (var i = 0; i + 1 < bytes.length; i++) { + if (bytes[i] == 0x0d && bytes[i + 1] == 0x0a) return i; + } + return -1; + } +} diff --git a/app/lib/protocols/spartan.dart b/app/lib/protocols/spartan.dart new file mode 100644 index 0000000..99939fc --- /dev/null +++ b/app/lib/protocols/spartan.dart @@ -0,0 +1,129 @@ +/// The Spartan protocol client: plaintext TCP on port 300, a request line of +/// ` ` CRLF optionally followed by that many +/// bytes of body, and a response of ` ` CRLF plus a body on +/// success. There is no TLS, so no identity and no trust to decide: anyone +/// on the line can read or write the conversation. +library; + +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +/// The whole response of one request, redirects already followed. +class SpartanResponse { + const SpartanResponse({ + required this.url, + required this.status, + required this.meta, + this.body, + }); + + /// The address the content finally came from, after redirects. + final Uri url; + + /// The single-digit status code: 2 success, 3 redirect, 4 client error, + /// 5 server error. + final int status; + + /// The MIME type on success, the target address on a redirect, an error + /// message otherwise. + final String meta; + + /// The response body, present on a success. + final Uint8List? body; + + bool get isSuccess => status == 2; + bool get isRedirect => status == 3; +} + +/// A connection, protocol or timeout failure. One failure to the reader. +class SpartanException implements Exception { + const SpartanException(this.message); + + final String message; + + @override + String toString() => message; +} + +/// The same address redirected more times than the client follows. +class SpartanTooManyRedirects implements Exception { + const SpartanTooManyRedirects(); +} + +class SpartanClient { + SpartanClient({this.timeout = const Duration(seconds: 20)}); + + static const defaultPort = 300; + static const maxRedirects = 5; + + final Duration timeout; + + /// Fetches [url], following status 3 redirects up to [maxRedirects] times. + /// [upload] becomes the request body, the protocol's only way of sending + /// data to a server. + Future fetch(Uri url, {Uint8List? upload}) async { + var current = url; + for (var hops = 0;; hops++) { + final response = await _requestOnce(current, upload); + if (!response.isRedirect) return response; + if (hops >= maxRedirects) throw const SpartanTooManyRedirects(); + final target = current.resolve(response.meta); + // Only spartan addresses are followed; anything else the redirect + // points to is left for the reader to open as its own navigation. + if (!target.hasScheme || target.scheme != 'spartan' || target.host.isEmpty) { + return response; + } + current = target; + } + } + + Future _requestOnce(Uri url, Uint8List? upload) async { + final host = url.host; + final port = url.hasPort ? url.port : defaultPort; + final path = url.path.isEmpty ? '/' : url.path + (url.hasQuery ? '?${url.query}' : ''); + + final Socket socket; + try { + socket = await Socket.connect(host, port, timeout: timeout); + } catch (_) { + throw const SpartanException('could not connect'); + } + + try { + final length = upload?.length ?? 0; + socket.write('$host $path $length\r\n'); + if (upload != null) socket.add(upload); + await socket.flush(); + + final raw = await _readToEnd(socket).timeout(timeout); + final headerEnd = _indexOfCrlf(raw); + if (headerEnd < 1) throw const SpartanException('response without a status line'); + final header = String.fromCharCodes(raw.sublist(0, headerEnd)); + if (header.length < 3 || header.codeUnitAt(1) != 0x20) { + throw const SpartanException('malformed status line'); + } + final status = header.codeUnitAt(0) - 0x30; + if (status < 0 || status > 9) throw const SpartanException('malformed status line'); + final body = status == 2 ? Uint8List.fromList(raw.sublist(headerEnd + 2)) : null; + return SpartanResponse(url: url, status: status, meta: header.substring(2), body: body); + } finally { + socket.destroy(); + } + } + + static Future _readToEnd(Socket socket) async { + final builder = BytesBuilder(copy: false); + await for (final chunk in socket) { + builder.add(chunk); + } + return builder.toBytes(); + } + + static int _indexOfCrlf(Uint8List bytes) { + for (var i = 0; i + 1 < bytes.length; i++) { + if (bytes[i] == 0x0d && bytes[i + 1] == 0x0a) return i; + } + return -1; + } +} diff --git a/app/lib/protocols/tofu.dart b/app/lib/protocols/tofu.dart new file mode 100644 index 0000000..1020d88 --- /dev/null +++ b/app/lib/protocols/tofu.dart @@ -0,0 +1,56 @@ +/// Trust-on-first-use pinning for the Gemini protocol. Gemini hosts use +/// self-signed certificates with no authority behind them, so the client +/// itself decides what to trust: the first visit pins the server +/// certificate's fingerprint, and later visits compare against that pin. +/// +/// A fingerprint change alone is ambiguous: it is the routine path when an +/// operator renews an expired certificate and the alarm path when someone +/// else is on the line. The decision below is expiry-aware, so the common +/// renewal never interrupts the reader while an unexplained change, one that +/// happens while the pinned certificate is still valid, does. +library; + + +import 'package:crypto/crypto.dart'; + +/// A pinned server certificate: its SHA-256 fingerprint over the DER +/// encoding, and the certificate's own end of validity, which tells a later +/// change from a renewal. +class TofuPin { + const TofuPin({required this.fingerprint, required this.expires}); + + /// Sixty-four lowercase hex characters. + final String fingerprint; + final DateTime expires; + + @override + bool operator ==(Object other) => other is TofuPin && other.fingerprint == fingerprint; + + @override + int get hashCode => fingerprint.hashCode; +} + +/// The pin of a DER-encoded certificate. +TofuPin pinOf(List derCertificate, {required DateTime expires}) => + TofuPin(fingerprint: sha256.convert(derCertificate).toString(), expires: expires); + +/// What the client should do with a presented certificate. +enum TofuDecision { + /// Nothing is pinned yet; accept and pin what was presented. + firstUse, + + /// The presented certificate matches the pin. + match, + + /// The pin has expired and the presented certificate replaces it. + rePin, + + /// The pin is still valid and the presented certificate is not it. + refuse, +} + +TofuDecision decideTofu(TofuPin? pin, String fingerprint, DateTime now) { + if (pin == null) return TofuDecision.firstUse; + if (pin.fingerprint == fingerprint) return TofuDecision.match; + return pin.expires.isAfter(now) ? TofuDecision.refuse : TofuDecision.rePin; +} diff --git a/app/locales/de/main.ftl b/app/locales/de/main.ftl index 5e74386..f11f182 100644 --- a/app/locales/de/main.ftl +++ b/app/locales/de/main.ftl @@ -1,8 +1,8 @@ ## Reader chrome -reader-address-placeholder = Webadresse eingeben +reader-address-placeholder = Adresse eingeben # Accessibility label for the address field. -reader-address-label = Webadresse +reader-address-label = Adresse reader-reload = Diese Seite neu laden reader-open-hub = Deine Mews-Seiten öffnen reader-back = Zurück @@ -141,6 +141,28 @@ settings-variant-mono = Monochrom settings-language-heading = Sprache settings-language-system = Systemeinstellung folgen +## Zertifikate + +certificates-title = Zertifikate +certificates-add = Zertifikat hinzufügen +certificates-pick-title = Zertifikat auswählen +certificates-pick-cert-title = Das Zertifikat auswählen, das zum Schlüssel gehört +certificates-pick-key-title = Den Schlüssel auswählen, der zum Zertifikat gehört +certificates-empty = Zertifikate, die du hinzufügst, erscheinen hier. +certificates-default-name = Zertifikat +certificates-rename-label = Neuer Name +certificates-rename-submit = Namen speichern +certificates-remove = Entfernen +certificates-delete-confirm = Wenn du dieses Zertifikat entfernst, wird es den Seiten, die es kennen, auch nicht mehr gezeigt. +certificates-delete-yes = Zertifikat entfernen +certificates-delete-no = Behalten +certificates-bound-to = Gezeigt an { $host } +certificates-choose = Diese Seite fragt nach einem Zertifikat. Wähle eines aus. +error-certificate-not-authorized = Die Seite hat das vorgelegte Zertifikat nicht angenommen. +error-certificate-invalid = Das vorgelegte Zertifikat ist ungültig. + +settings-identities-heading = Gemini-Identitäten + ## Languages # Every language is named in its own words, so a reader who cannot read the @@ -170,3 +192,8 @@ error-broken-page-text-title = Seitentext error-non-mews = Dies ist keine Mews-Seite und wird deshalb schlicht angezeigt. error-image-blocked = Dieses Bild liegt auf einer anderen Website und wird nicht angezeigt. error-image-unavailable = Dieses Bild konnte nicht geladen werden. +error-protocol-failed = Die Website hat das Anzeigen dieser Seite abgelehnt. +error-redirect-loop = Diese Seite leitet öfter weiter, als Mews folgt. +error-unsupported-type = Diese Seite enthält Inhalt, den Mews nicht anzeigen kann. +error-certificate-changed = Das Zertifikat, das diese Website vorgelegt hat, ist nicht das, dem du vertraut hast. Vertraue dem neuen Zertifikat nur, wenn du weißt, warum es sich geändert hat. +error-certificate-trust = Dem neuen Zertifikat vertrauen diff --git a/app/locales/en-US/main.ftl b/app/locales/en-US/main.ftl index f825f6a..298a0f2 100644 --- a/app/locales/en-US/main.ftl +++ b/app/locales/en-US/main.ftl @@ -1,8 +1,8 @@ ## Reader chrome -reader-address-placeholder = Enter a web address +reader-address-placeholder = Enter an address # Accessibility label for the address field. -reader-address-label = Web address +reader-address-label = Address reader-reload = Reload this page reader-open-hub = Open your Mews pages reader-back = Go back @@ -141,6 +141,31 @@ settings-variant-mono = Mono settings-language-heading = Language settings-language-system = Follow system +## Certificates + +certificates-title = Certificates +certificates-add = Add a certificate +certificates-pick-title = Choose a certificate +certificates-pick-cert-title = Choose the certificate that goes with the key +certificates-pick-key-title = Choose the key that goes with the certificate +certificates-empty = Certificates you add will appear here. +certificates-default-name = Certificate +certificates-rename-label = New name +certificates-rename-submit = Save name +certificates-remove = Remove +certificates-delete-confirm = Removing this certificate also stops presenting it to the sites that know it. +certificates-delete-yes = Remove the certificate +certificates-delete-no = Keep it +# The host a certificate is presented to. +certificates-bound-to = Presented to { $host } +certificates-choose = This site asks for a certificate. Choose one to present. +error-certificate-not-authorized = The site did not accept the certificate you presented. +error-certificate-invalid = The certificate you presented is not valid. + +## Settings page additions + +settings-identities-heading = Gemini Identities + ## Languages # Every language is named in its own words, so a reader who cannot read the @@ -170,3 +195,8 @@ error-broken-page-text-title = Page text error-non-mews = This page is not a Mews page, so it is shown plainly. error-image-blocked = This image is on another site and is not shown. error-image-unavailable = This image could not be loaded. +error-protocol-failed = The site refused to show this page. +error-redirect-loop = This page redirects more times than Mews follows. +error-unsupported-type = This page is a kind of content Mews cannot show. +error-certificate-changed = The certificate this site presented is not the one trusted for it. Trust the new certificate only if you know why it changed. +error-certificate-trust = Trust the new certificate diff --git a/app/locales/es/main.ftl b/app/locales/es/main.ftl index bd0aabc..cb5aa19 100644 --- a/app/locales/es/main.ftl +++ b/app/locales/es/main.ftl @@ -1,8 +1,8 @@ ## Reader chrome -reader-address-placeholder = Escribe una dirección web +reader-address-placeholder = Escribe una dirección # Accessibility label for the address field. -reader-address-label = Dirección web +reader-address-label = Dirección reader-reload = Recargar esta página reader-open-hub = Abrir tus páginas de Mews reader-back = Atrás @@ -141,6 +141,28 @@ settings-variant-mono = Monocromo settings-language-heading = Idioma settings-language-system = Seguir al sistema +## Certificados + +certificates-title = Certificados +certificates-add = Añadir un certificado +certificates-pick-title = Elige un certificado +certificates-pick-cert-title = Elige el certificado que va con la clave +certificates-pick-key-title = Elige la clave que va con el certificado +certificates-empty = Los certificados que añadas aparecerán aquí. +certificates-default-name = Certificado +certificates-rename-label = Nombre nuevo +certificates-rename-submit = Guardar nombre +certificates-remove = Quitar +certificates-delete-confirm = Al quitar este certificado, también dejará de presentarse a los sitios que lo conocen. +certificates-delete-yes = Quitar el certificado +certificates-delete-no = Conservarlo +certificates-bound-to = Presentado a { $host } +certificates-choose = Este sitio pide un certificado. Elige uno para presentarlo. +error-certificate-not-authorized = El sitio no aceptó el certificado que presentaste. +error-certificate-invalid = El certificado que presentaste no es válido. + +settings-identities-heading = Identidades de Gemini + ## Languages # Every language is named in its own words, so a reader who cannot read the @@ -170,3 +192,8 @@ error-broken-page-text-title = Texto de la página error-non-mews = Esta página no es una página de Mews, así que se muestra de forma sencilla. error-image-blocked = Esta imagen está en otro sitio y no se muestra. error-image-unavailable = No se ha podido cargar esta imagen. +error-protocol-failed = El sitio se negó a mostrar esta página. +error-redirect-loop = Esta página redirige más veces de las que Mews sigue. +error-unsupported-type = Esta página es un tipo de contenido que Mews no puede mostrar. +error-certificate-changed = El certificado que presentó este sitio no es el que tienes confiado. Confía en el nuevo certificado solo si sabes por qué cambió. +error-certificate-trust = Confiar en el nuevo certificado diff --git a/app/locales/fr/main.ftl b/app/locales/fr/main.ftl index ad9da39..12d2545 100644 --- a/app/locales/fr/main.ftl +++ b/app/locales/fr/main.ftl @@ -1,8 +1,8 @@ ## Reader chrome -reader-address-placeholder = Saisis une adresse web +reader-address-placeholder = Saisis une adresse # Accessibility label for the address field. -reader-address-label = Adresse web +reader-address-label = Adresse reader-reload = Recharger cette page reader-open-hub = Ouvrir tes pages Mews reader-back = Revenir en arrière @@ -141,6 +141,28 @@ settings-variant-mono = Monochrome settings-language-heading = Langue settings-language-system = Suivre le système +## Certificats + +certificates-title = Certificats +certificates-add = Ajouter un certificat +certificates-pick-title = Choisissez un certificat +certificates-pick-cert-title = Choisissez le certificat qui va avec la clé +certificates-pick-key-title = Choisissez la clé qui va avec le certificat +certificates-empty = Les certificats que vous ajoutez apparaîtront ici. +certificates-default-name = Certificat +certificates-rename-label = Nouveau nom +certificates-rename-submit = Enregistrer le nom +certificates-remove = Retirer +certificates-delete-confirm = Retirer ce certificat arrête aussi de le présenter aux sites qui le connaissent. +certificates-delete-yes = Retirer le certificat +certificates-delete-no = Le garder +certificates-bound-to = Présenté à { $host } +certificates-choose = Ce site demande un certificat. Choisissez-en un à présenter. +error-certificate-not-authorized = Le site n'a pas accepté le certificat que vous avez présenté. +error-certificate-invalid = Le certificat que vous avez présenté n'est pas valide. + +settings-identities-heading = Identités Gemini + ## Languages # Every language is named in its own words, so a reader who cannot read the @@ -170,3 +192,8 @@ error-broken-page-text-title = Texte de la page error-non-mews = Cette page n'est pas une page Mews, elle est donc affichée simplement. error-image-blocked = Cette image se trouve sur un autre site et n'est pas affichée. error-image-unavailable = Cette image n'a pas pu être chargée. +error-protocol-failed = Le site a refusé d'afficher cette page. +error-redirect-loop = Cette page redirige plus de fois que Mews n'en suit. +error-unsupported-type = Cette page est un type de contenu que Mews ne peut pas afficher. +error-certificate-changed = Le certificat présenté par ce site n'est pas celui auquel vous faites confiance. Faites confiance au nouveau certificat seulement si vous savez pourquoi il a changé. +error-certificate-trust = Faire confiance au nouveau certificat diff --git a/app/locales/he/main.ftl b/app/locales/he/main.ftl index e453275..c62a22e 100644 --- a/app/locales/he/main.ftl +++ b/app/locales/he/main.ftl @@ -1,8 +1,8 @@ ## Reader chrome -reader-address-placeholder = הזינו כתובת אינטרנט +reader-address-placeholder = הזינו כתובת # Accessibility label for the address field. -reader-address-label = כתובת אינטרנט +reader-address-label = כתובת reader-reload = טעינה מחדש של הדף reader-open-hub = פתיחת דפי Mews שלכם reader-back = חזרה @@ -146,6 +146,28 @@ settings-variant-mono = מונוכרום settings-language-heading = שפה settings-language-system = לפי המערכת +## אישורים + +certificates-title = אישורים +certificates-add = הוספת אישור +certificates-pick-title = בחרו אישור +certificates-pick-cert-title = בחרו את האישור שנלווה למפתח +certificates-pick-key-title = בחרו את המפתח שנלווה לאישור +certificates-empty = אישורים שתוסיפו יופיעו כאן. +certificates-default-name = אישור +certificates-rename-label = שם חדש +certificates-rename-submit = שמירת השם +certificates-remove = הסרה +certificates-delete-confirm = הסרת האישור הזה תגרום גם להפסקת הצגתו בפני האתרים שמכירים אותו. +certificates-delete-yes = הסרת האישור +certificates-delete-no = להשאיר אותו +certificates-bound-to = מוצג בפני { $host } +certificates-choose = האתר הזה מבקש אישור. בחרו אישור להצגה. +error-certificate-not-authorized = האתר לא קיבל את האישור שהצגתם. +error-certificate-invalid = האישור שהצגתם אינו תקין. + +settings-identities-heading = זהויות Gemini + ## Languages # Every language is named in its own words, so a reader who cannot read the @@ -175,3 +197,8 @@ error-broken-page-text-title = הטקסט של הדף error-non-mews = הדף הזה אינו דף Mews, ולכן הוא מוצג בפשטות. error-image-blocked = התמונה הזו נמצאת באתר אחר ואינה מוצגת. error-image-unavailable = לא ניתן לטעון את התמונה הזו. +error-protocol-failed = האתר סירב להציג את הדף הזה. +error-redirect-loop = הדף הזה מפנה הלאה יותר פעמים מכפי ש-Mews עוקב. +error-unsupported-type = הדף הזה הוא סוג תוכן ש-Mews לא יכול להציג. +error-certificate-changed = האישור שהאתר הזה הציג אינו האישור שניתן בו אמון. תנו אמון באישור החדש רק אם אתם יודעים מדוע הוא השתנה. +error-certificate-trust = מתן אמון באישור החדש diff --git a/app/locales/ja/main.ftl b/app/locales/ja/main.ftl index 0602648..26e5ce9 100644 --- a/app/locales/ja/main.ftl +++ b/app/locales/ja/main.ftl @@ -1,8 +1,8 @@ ## Reader chrome -reader-address-placeholder = ウェブアドレスを入力 +reader-address-placeholder = アドレスを入力 # Accessibility label for the address field. -reader-address-label = ウェブアドレス +reader-address-label = アドレス reader-reload = このページを再読み込み reader-open-hub = Mews のページを開く reader-back = 戻る @@ -141,6 +141,28 @@ settings-variant-mono = モノクロ settings-language-heading = 言語 settings-language-system = システムに合わせる +## 証明書 + +certificates-title = 証明書 +certificates-add = 証明書を追加する +certificates-pick-title = 証明書を選んでください +certificates-pick-cert-title = 鍵と対になる証明書を選んでください +certificates-pick-key-title = 証明書と対になる鍵を選んでください +certificates-empty = 追加した証明書はここに表示されます。 +certificates-default-name = 証明書 +certificates-rename-label = 新しい名前 +certificates-rename-submit = 名前を保存する +certificates-remove = 削除する +certificates-delete-confirm = この証明書を削除すると、それを知っているサイトに提示しなくなります。 +certificates-delete-yes = 証明書を削除する +certificates-delete-no = 残す +certificates-bound-to = { $host } に提示 +certificates-choose = このサイトは証明書を求めています。提示するものを選んでください。 +error-certificate-not-authorized = サイトは提示された証明書を受け付けませんでした。 +error-certificate-invalid = 提示された証明書は有効ではありません。 + +settings-identities-heading = Gemini ID + ## Languages # Every language is named in its own words, so a reader who cannot read the @@ -170,3 +192,8 @@ error-broken-page-text-title = ページのテキスト error-non-mews = これは Mews のページではないため、簡素に表示しています。 error-image-blocked = この画像は別のサイトにあるため表示しません。 error-image-unavailable = この画像を読み込めませんでした。 +error-protocol-failed = このサイトはこのページの表示を拒否しました。 +error-redirect-loop = このページはMewsがたどる回数より多く転送されます。 +error-unsupported-type = このページはMewsが表示できない種類のコンテンツです。 +error-certificate-changed = このサイトが提示した証明書は、信頼されているものではありません。変更された理由がわかる場合にのみ、新しい証明書を信頼してください。 +error-certificate-trust = 新しい証明書を信頼する diff --git a/app/locales/pt/main.ftl b/app/locales/pt/main.ftl index 2c402a5..62ef4a9 100644 --- a/app/locales/pt/main.ftl +++ b/app/locales/pt/main.ftl @@ -1,8 +1,8 @@ ## Reader chrome -reader-address-placeholder = Escreve um endereço web +reader-address-placeholder = Escreve um endereço # Accessibility label for the address field. -reader-address-label = Endereço web +reader-address-label = Endereço reader-reload = Recarregar esta página reader-open-hub = Abrir as tuas páginas Mews reader-back = Retroceder @@ -141,6 +141,28 @@ settings-variant-mono = Monocromático settings-language-heading = Idioma settings-language-system = Seguir o sistema +## Certificados + +certificates-title = Certificados +certificates-add = Adicionar um certificado +certificates-pick-title = Escolhe um certificado +certificates-pick-cert-title = Escolhe o certificado que acompanha a chave +certificates-pick-key-title = Escolhe a chave que acompanha o certificado +certificates-empty = Os certificados que adicionares aparecerão aqui. +certificates-default-name = Certificado +certificates-rename-label = Nome novo +certificates-rename-submit = Guardar nome +certificates-remove = Remover +certificates-delete-confirm = Remover este certificado também deixa de o apresentar aos sites que o conhecem. +certificates-delete-yes = Remover o certificado +certificates-delete-no = Mantê-lo +certificates-bound-to = Apresentado a { $host } +certificates-choose = Este site pede um certificado. Escolhe um para apresentar. +error-certificate-not-authorized = O site não aceitou o certificado que apresentaste. +error-certificate-invalid = O certificado que apresentaste não é válido. + +settings-identities-heading = Identidades Gemini + ## Languages # Every language is named in its own words, so a reader who cannot read the @@ -170,3 +192,8 @@ error-broken-page-text-title = Texto da página error-non-mews = Esta não é uma página Mews, por isso é mostrada de forma simples. error-image-blocked = Esta imagem está noutro site e não é mostrada. error-image-unavailable = Não foi possível carregar esta imagem. +error-protocol-failed = O site recusou mostrar esta página. +error-redirect-loop = Esta página redireciona mais vezes do que o Mews segue. +error-unsupported-type = Esta página é um tipo de conteúdo que o Mews não pode mostrar. +error-certificate-changed = O certificado que este site apresentou não é aquele em que você confia. Confie no novo certificado apenas se souber por que ele mudou. +error-certificate-trust = Confiar no novo certificado diff --git a/app/locales/ru/main.ftl b/app/locales/ru/main.ftl index 57b771e..9a2afc6 100644 --- a/app/locales/ru/main.ftl +++ b/app/locales/ru/main.ftl @@ -1,8 +1,8 @@ ## Reader chrome -reader-address-placeholder = Введите веб-адрес +reader-address-placeholder = Введите адрес # Accessibility label for the address field. -reader-address-label = Веб-адрес +reader-address-label = Адрес reader-reload = Обновить эту страницу reader-open-hub = Открыть ваши страницы Mews reader-back = Назад @@ -146,6 +146,28 @@ settings-variant-mono = Монохромные settings-language-heading = Язык settings-language-system = Как в системе +## Сертификаты + +certificates-title = Сертификаты +certificates-add = Добавить сертификат +certificates-pick-title = Выберите сертификат +certificates-pick-cert-title = Выберите сертификат, который прилагается к ключу +certificates-pick-key-title = Выберите ключ, который прилагается к сертификату +certificates-empty = Сертификаты, которые вы добавите, появятся здесь. +certificates-default-name = Сертификат +certificates-rename-label = Новое имя +certificates-rename-submit = Сохранить имя +certificates-remove = Убрать +certificates-delete-confirm = Если убрать этот сертификат, он также перестанет предъявляться сайтам, которые его знают. +certificates-delete-yes = Убрать сертификат +certificates-delete-no = Оставить +certificates-bound-to = Предъявляется сайту { $host } +certificates-choose = Этот сайт просит сертификат. Выберите, какой предъявить. +error-certificate-not-authorized = Сайт не принял предъявленный вами сертификат. +error-certificate-invalid = Предъявленный вами сертификат недействителен. + +settings-identities-heading = Удостоверения Gemini + ## Languages # Every language is named in its own words, so a reader who cannot read the @@ -175,3 +197,8 @@ error-broken-page-text-title = Текст страницы error-non-mews = Это не страница Mews, поэтому она показана простым текстом. error-image-blocked = Это изображение находится на другом сайте и не показывается. error-image-unavailable = Не удалось загрузить изображение. +error-protocol-failed = Сайт отказался показать эту страницу. +error-redirect-loop = Эта страница перенаправляет больше раз, чем Mews может пройти. +error-unsupported-type = Эта страница — тип содержимого, который Mews не может показать. +error-certificate-changed = Сертификат, который показал этот сайт, не тот, которому вы доверяли. Доверяйте новому сертификату, только если знаете, почему он изменился. +error-certificate-trust = Доверять новому сертификату diff --git a/app/pubspec.lock b/app/pubspec.lock index 9d7dada..c443565 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -25,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "10.0.1" + android_file_picker: + dependency: transitive + description: + name: android_file_picker + sha256: "537bce9a43bc6fc08670cb68498251acc6eb9134b86a391b43e1f7c863588388" + url: "https://pub.dev" + source: hosted + version: "2.0.1" args: dependency: transitive description: @@ -161,8 +169,16 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" - crypto: + cross_file: dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" + crypto: + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -177,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.7" + dbus: + dependency: transitive + description: + name: dbus + sha256: f7db8504e715b835f5ebe5fe74604ccd961d504c73389a5d8e81bafc2e2f3612 + url: "https://pub.dev" + source: hosted + version: "0.8.0" drift: dependency: "direct main" description: @@ -209,6 +233,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: @@ -217,6 +249,46 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "98c0b156b6380ba55bc767a14e2e6b3feb246e713ad40092424596fa79005bea" + url: "https://pub.dev" + source: hosted + version: "13.1.0" + file_picker_darwin: + dependency: transitive + description: + name: file_picker_darwin + sha256: cd4b60bdba061ca5a6cede9141cb6816707eace5618ee469b5f46511eba7897b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file_picker_linux: + dependency: transitive + description: + name: file_picker_linux + sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da + url: "https://pub.dev" + source: hosted + version: "2.0.2" + file_picker_platform_interface: + dependency: transitive + description: + name: file_picker_platform_interface + sha256: a78bd8ec992ae34ce32f57157422bd532967510d2ebb6fc251756472bbde7160 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + file_picker_web: + dependency: transitive + description: + name: file_picker_web + sha256: "32af8262aca907b02b8970b32328b0312db0b42ba6f930be6d27fde179d59b7d" + url: "https://pub.dev" + source: hosted + version: "4.1.0" fixnum: dependency: transitive description: @@ -264,6 +336,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -781,6 +858,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + windows_file_picker: + dependency: transitive + description: + name: windows_file_picker + sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072" + url: "https://pub.dev" + source: hosted + version: "2.0.1" xdg_directories: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 4787ac2..a460a98 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -1,5 +1,5 @@ name: mews -description: "A reader for Mews pages: local-first, reader-controlled presentation." +description: "A reader for Mews pages, Gemini capsules and Spartan sites: local-first, reader-controlled presentation." publish_to: 'none' version: 0.1.0+1 @@ -22,6 +22,8 @@ dependencies: sqlite3_flutter_libs: ^0.6.0 path: ^1.9.0 path_provider: ^2.1.6 + crypto: ^3.0.7 + file_picker: ^13.1.0 dev_dependencies: flutter_test: diff --git a/app/test/browser_controller_test.dart b/app/test/browser_controller_test.dart new file mode 100644 index 0000000..ba7c4d5 --- /dev/null +++ b/app/test/browser_controller_test.dart @@ -0,0 +1,103 @@ +import 'dart:io'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:mews/application/controllers/browser_controller.dart'; +import 'package:mews/application/key_store.dart'; +import 'package:mews/database/database.dart'; +import 'package:mews/protocols/tofu.dart'; + +import 'fake_fetch.dart'; + +void main() { + late MewsDatabase db; + late Directory keyDir; + late KeyStore keys; + + setUp(() async { + db = MewsDatabase.test(NativeDatabase.memory()); + keyDir = await Directory.systemTemp.createTemp('mews-keys'); + keys = KeyStore(() async => keyDir); + }); + + tearDown(() async { + await db.close(); + keyDir.deleteSync(recursive: true); + }); + + BrowserController browser(FakeFetch fetch) => + BrowserController(db: db, fetch: fetch, keys: keys); + + test('a bare hostname that answers on gemini is found there', () async { + final controller = + browser(FakeFetch({'gemini://capsule.example': '# Hello\n\nText.'})); + + await controller.open('capsule.example'); + + expect(controller.error.value, isNull); + expect(controller.currentPage.value!.url, 'gemini://capsule.example'); + expect(controller.currentPage.value!.title, 'Hello'); + }); + + test('a bare hostname that answers on spartan is found there', () async { + final controller = + browser(FakeFetch({'spartan://capsule.example': '# Hello\n\nText.'})); + + await controller.open('capsule.example'); + + expect(controller.error.value, isNull); + expect(controller.currentPage.value!.url, 'spartan://capsule.example'); + }); + + test('an explicit scheme is not second-guessed', () async { + final controller = + browser(FakeFetch({'gemini://capsule.example': '# Hello\n\nText.'})); + + // The reader named the web, so a missing web page is an error, not an + // invitation to try the capsule protocols. + await controller.open('https://capsule.example'); + + expect(controller.error.value, isA()); + expect(controller.currentPage.value, isNull); + }); + + test('a changed certificate blocks until the reader trusts the new one', () async { + final presented = TofuPin(fingerprint: 'new', expires: DateTime(2036)); + final controller = browser(FakeFetch( + {'gemini://capsule.example': '# Hello\n\nText.'}, + changedCertificates: {'gemini://capsule.example': presented}, + )); + + await controller.open('gemini://capsule.example'); + expect(controller.error.value, isA()); + + // Trusting records the presented certificate as the host's new pin. + await controller.trustPresentedCertificate(); + expect(await db.allPins(), {'capsule.example:1965': presented}); + }); + + test('a certificate request is answered by binding an identity', () async { + final controller = browser(FakeFetch( + {'gemini://capsule.example': '# Hello\n\nText.'}, + certificateRequired: {'gemini://capsule.example'}, + )); + final id = await db.addClientCertificate('Reader', 'CERT PEM'); + await keys.save(id, 'KEY PEM'); + + // The site asks before anything shows; the reader's answer is a choice, + // not an automatic presentation. + await controller.open('gemini://capsule.example'); + expect(controller.error.value, isA()); + + await controller.chooseCertificate(id); + expect(await db.bindingFor('capsule.example', 1965), id); + expect(controller.error.value, isNull); + expect(controller.currentPage.value!.title, 'Hello'); + + // The binding is worn on the next visit too, without asking again. + await controller.open('gemini://capsule.example'); + expect(controller.error.value, isNull); + expect(controller.currentPage.value!.title, 'Hello'); + }); +} diff --git a/app/test/certificates_page_test.dart b/app/test/certificates_page_test.dart new file mode 100644 index 0000000..97fcfb6 --- /dev/null +++ b/app/test/certificates_page_test.dart @@ -0,0 +1,211 @@ +import 'dart:io'; + +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show RenderParagraph; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:mews/application/key_store.dart'; +import 'package:mews/database/database.dart'; +import 'package:mews/l10n/strings.dart'; +import 'package:mews/presentation/app.dart'; + +import 'fake_fetch.dart'; + +/// A PEM holding both the certificate and its key, the way a Gemini identity +/// commonly travels. +String combinedPem() => + '${File('test/fixtures/client-test-cert.pem').readAsStringSync()}\n' + '${File('test/fixtures/client-test-key.pem').readAsStringSync()}'; + +/// Taps the run reading [label] inside a rendered paragraph; internal pages +/// put several links in one paragraph, so tapping the widget's center would +/// hit whichever run happens to sit in the middle. +Future tapRun(WidgetTester tester, String label) async { + final paragraph = tester.renderObject( + find + .descendant( + of: find.byWidgetPredicate((widget) => + widget is Text && + (widget.textSpan?.toPlainText() ?? widget.data ?? '') + .contains(label)), + matching: find.byType(RichText), + ) + .first, + ); + final plain = paragraph.text.toPlainText(); + final start = plain.indexOf(label); + final box = paragraph + .getBoxesForSelection( + TextSelection(baseOffset: start, extentOffset: start + label.length)) + .first; + await tester.tapAt( + paragraph.localToGlobal(Offset(box.left + 2, (box.top + box.bottom) / 2))); + await tester.pumpAndSettle(); +} + +void main() { + late MewsDatabase db; + late Directory keyDir; + late Strings strings; + late AppDependencies deps; + + setUp(() async { + db = MewsDatabase.test(NativeDatabase.memory()); + keyDir = await Directory.systemTemp.createTemp('mews-keys'); + strings = Strings(); + strings.loadSource(File('locales/en-US/main.ftl').readAsStringSync()); + deps = AppDependencies( + database: db, + fetch: FakeFetch(const {}), + strings: strings, + keys: KeyStore(() async => keyDir), + pickPemFile: (title) async => (name: 'reader', content: combinedPem()), + ); + }); + + tearDown(() async { + await db.close(); + keyDir.deleteSync(recursive: true); + }); + + Future pumpCertificates(WidgetTester tester) async { + await tester.pumpWidget(MewsApp(deps: deps, strings: strings)); + await tester.pumpAndSettle(); + await deps.browser.open('mews:certificates'); + await tester.pumpAndSettle(); + } + + testWidgets('a certificate is imported, renamed and removed in a second step', + (tester) async { + await pumpCertificates(tester); + expect(find.textContaining('Certificates you add'), findsOneWidget); + + // The pick is injected; its file name names the identity. The name shows + // in the row and prefills the rename form under it. + await tapRun(tester, 'Add a certificate'); + expect(find.textContaining('reader'), findsWidgets); + expect(await db.allClientCertificates(), hasLength(1)); + expect(await deps.keys.read(1), isNotEmpty); + // The address bar is a TextField too, so the form is the last one. + expect(find.byType(TextField), findsNWidgets(2)); + expect(tester.widget(find.byType(TextField).last).controller!.text, + 'reader'); + + // Renaming happens in place, on the form under the identity's row. + await tester.enterText(find.byType(TextField).last, 'Reader'); + await tester.tap(find.text('Save name')); + await tester.pumpAndSettle(); + expect(find.textContaining('Reader'), findsWidgets); + expect((await db.allClientCertificates()).single.name, 'Reader'); + + // Removing asks first, the way every irreversible act here does. + await tapRun(tester, 'Remove'); + expect(find.textContaining('stops presenting'), findsOneWidget); + await tapRun(tester, 'Keep it'); + await tester.pumpAndSettle(); + expect((await db.allClientCertificates()), hasLength(1)); + + await tapRun(tester, 'Remove'); + await tapRun(tester, 'Remove the certificate'); + expect(find.textContaining('Certificates you add'), findsOneWidget); + expect(await db.allClientCertificates(), isEmpty); + expect(await deps.keys.read(1), isNull); + }); + + testWidgets('a cancelled pick imports nothing', (tester) async { + final cancelledDeps = AppDependencies( + database: db, + fetch: FakeFetch(const {}), + strings: strings, + keys: KeyStore(() async => keyDir), + pickPemFile: (title) async => null, + ); + await tester.pumpWidget(MewsApp(deps: cancelledDeps, strings: strings)); + await tester.pumpAndSettle(); + await cancelledDeps.browser.open('mews:certificates'); + await tester.pumpAndSettle(); + + await tapRun(tester, 'Add a certificate'); + + expect(find.textContaining('Certificates you add'), findsOneWidget); + expect(await db.allClientCertificates(), isEmpty); + }); + + testWidgets('a key picked alone asks for its certificate', (tester) async { + final picks = []; + final keyOnly = AppDependencies( + database: db, + fetch: FakeFetch(const {}), + strings: strings, + keys: KeyStore(() async => keyDir), + pickPemFile: (title) async { + picks.add(title); + if (picks.length == 1) { + // A .key file alone: the key, but no certificate to wear it in. + return (name: 'reader-key', content: File('test/fixtures/client-test-key.pem').readAsStringSync()); + } + return ( + name: 'reader-cert', + content: File('test/fixtures/client-test-cert.pem').readAsStringSync() + ); + }, + ); + await tester.pumpWidget(MewsApp(deps: keyOnly, strings: strings)); + await tester.pumpAndSettle(); + await keyOnly.browser.open('mews:certificates'); + await tester.pumpAndSettle(); + + await tapRun(tester, 'Add a certificate'); + + // The second pick was asked for by name, so the key was not silently + // dropped, and the pair imported together. + expect(picks, ['Choose a certificate', 'Choose the certificate that goes with the key']); + expect(await db.allClientCertificates(), hasLength(1)); + expect(await keyOnly.keys.read(1), isNotEmpty); + expect(find.textContaining('reader-key'), findsWidgets); + }); + + testWidgets('a key picked alone with nothing after it imports nothing', + (tester) async { + final cancelled = AppDependencies( + database: db, + fetch: FakeFetch(const {}), + strings: strings, + keys: KeyStore(() async => keyDir), + pickPemFile: (title) async => title.contains('goes with') + ? null + : (name: 'reader-key', content: File('test/fixtures/client-test-key.pem').readAsStringSync()), + ); + await tester.pumpWidget(MewsApp(deps: cancelled, strings: strings)); + await tester.pumpAndSettle(); + await cancelled.browser.open('mews:certificates'); + await tester.pumpAndSettle(); + + await tapRun(tester, 'Add a certificate'); + + // A key alone cannot be worn, and cancelling the second pick keeps + // nothing of it. + expect(await db.allClientCertificates(), isEmpty); + expect(await cancelled.keys.read(1), isNull); + expect(find.textContaining('Certificates you add'), findsOneWidget); + }); + + testWidgets('a certificate is presented only to the sites that ask for it', + (tester) async { + final id = await db.addClientCertificate('Reader', 'CERT PEM'); + await db.bindCertificate('capsule.example', 1965, id); + await pumpCertificates(tester); + + // The row names the site the identity is worn at. + expect(find.textContaining('capsule.example:1965'), findsOneWidget); + + // Removing the identity takes the binding with it, so the site is left + // asking rather than pointing at a certificate that is no longer there. + await db.deleteClientCertificate(id); + await deps.browser.reload(); + await tester.pumpAndSettle(); + expect(find.textContaining('Certificates you add'), findsOneWidget); + expect(await db.bindingFor('capsule.example', 1965), isNull); + }); +} diff --git a/app/test/database_test.dart b/app/test/database_test.dart index 35babfb..bc9ae53 100644 --- a/app/test/database_test.dart +++ b/app/test/database_test.dart @@ -6,6 +6,7 @@ import 'package:path/path.dart' as p; import 'package:mews/database/database.dart'; import 'package:mews/mews/document.dart'; +import 'package:mews/protocols/tofu.dart'; MewsEntry _entry(String url, {String title = 'A post', int day = 8}) => MewsEntry(url: url, date: DateTime(2026, 10, day), title: title); @@ -175,4 +176,41 @@ void main() { await db.removeBookmark(url); expect(await db.tagsOf(TagKind.bookmark, url), isEmpty); }); + + test('pins are saved by host and port and replaced on renewal', () async { + final pin = TofuPin(fingerprint: 'aa', expires: DateTime(2030)); + await db.savePin('capsule.example', 1965, pin); + await db.savePin('capsule.example', 1966, pin); + + expect(await db.allPins(), { + 'capsule.example:1965': pin, + 'capsule.example:1966': pin, + }); + + // A renewal or a reader's new trust replaces the pin for that host. + final renewed = TofuPin(fingerprint: 'bb', expires: DateTime(2036)); + await db.savePin('capsule.example:1965'.split(':').first, 1965, renewed); + expect((await db.allPins())['capsule.example:1965'], renewed); + }); + + test('bindings tie identities to hosts and follow the identity out', () async { + final id = await db.addClientCertificate('Reader', 'CERT PEM'); + await db.bindCertificate('capsule.example', 1965, id); + // Binding again, not twice. + await db.bindCertificate('capsule.example', 1965, id); + + expect(await db.bindingFor('capsule.example', 1965), id); + expect(await db.bindingsOf(id), ['capsule.example:1965']); + + // Renaming an identity is its name only; where it is worn stays. + await db.renameClientCertificate(id, 'Reader Two'); + expect((await db.allClientCertificates()).single.name, 'Reader Two'); + expect(await db.bindingFor('capsule.example', 1965), id); + + // Removing an identity takes its bindings with it, so no site is left + // pointing at a certificate that is no longer there. + await db.deleteClientCertificate(id); + expect(await db.allClientCertificates(), isEmpty); + expect(await db.bindingFor('capsule.example', 1965), isNull); + }); } diff --git a/app/test/fake_fetch.dart b/app/test/fake_fetch.dart index 26429de..ae14eff 100644 --- a/app/test/fake_fetch.dart +++ b/app/test/fake_fetch.dart @@ -1,39 +1,100 @@ +import 'dart:typed_data'; + import 'package:actors/actors.dart'; import 'package:mews/application/actors/fetch_actor.dart'; import 'package:mews/mews/atom.dart'; +import 'package:mews/mews/gemtext.dart'; import 'package:mews/mews/parser.dart'; +import 'package:mews/protocols/tofu.dart'; /// Serves pages from an in-memory map without network or isolates, so /// navigation tests are deterministic. [sources] wins over the cached source /// a caller offers, the way a server answering 200 does, so a test can change /// what a site publishes between two fetches. An address with no source is /// unreachable, and the cached copy stands in for it if there is one. +/// +/// A gemini or spartan address is served as gemtext, the way the real handler +/// routes protocol pages, and an address in [changedCertificates] refuses its +/// connection the way a changed server certificate does. class FakeFetch implements Actor { - FakeFetch(this.sources, {this.feeds = const {}}); + FakeFetch(this.sources, + {this.feeds = const {}, + this.changedCertificates = const {}, + this.certificateRequired = const {}, + this.images = const {}}); final Map sources; /// Atom feeds by address, for sites that publish through one. final Map feeds; + /// Gemini addresses whose server certificate is not the pinned one, with + /// the pin of what was presented instead. + final Map changedCertificates; + + /// Gemini addresses that ask for a client certificate. They answer with a + /// certificate request until an identity is presented, then serve the page. + final Set certificateRequired; + + /// Raw bodies by address, the way a capsule serves an image. + final Map images; + @override Future send(FetchCommand message) async { return switch (message) { ParseSource(:final url, :final source) => _parse(url, source), - FetchPage(:final url, :final cachedSource) => switch (( - sources[url], - cachedSource, - )) { - (final String fresh, _) => _parse(url, fresh), - (null, final String cached) => _parse(url, cached), - _ => const FetchNetworkError(), + FetchPage(:final url, :final cachedSource, :final clientCert) => + _serve(url, cachedSource, clientCert), + SubmitForm(:final url, :final fields) => + _serve(_withQuery(url, fields), null, null), + FetchBytes(:final url) => switch (images[url]) { + final Uint8List data => FetchBytesOk(data), + null => const FetchNetworkError(), }, FetchFeed(:final url) => switch (feeds[url]) { final String source => FetchFeedOk(url: url, entries: parseAtomEntries(source: source, feedUrl: url)), null => const FetchNetworkError(), }, + }; + } + + /// An answer to a form on a protocol page carries the fields as the + /// address's query string; a web form posts to its action unchanged. + String _withQuery(String url, Map fields) { + final uri = Uri.tryParse(url); + if (uri == null) return url; + if (uri.scheme != 'gemini' && uri.scheme != 'spartan') return url; + return uri.replace(queryParameters: {...uri.queryParameters, ...fields}).toString(); + } + + FetchResult _serve(String url, String? cachedSource, String? clientCert) { + final uri = Uri.tryParse(url); + if (uri != null && (uri.scheme == 'gemini' || uri.scheme == 'spartan')) { + if (certificateRequired.contains(url) && clientCert == null) { + return const FetchClientCertificateRequired(status: 60, meta: ''); + } + final presented = changedCertificates[url]; + if (presented != null) { + return FetchCertificateChanged( + host: uri.host, + port: uri.hasPort ? uri.port : (uri.scheme == 'gemini' ? 1965 : 300), + presented: presented); + } + return switch ((sources[url], cachedSource)) { + (final String fresh, _) => FetchPageOk( + page: parseGemtextPage(source: fresh, url: url), source: fresh), + (null, final String cached) => FetchPageOk( + page: parseGemtextPage(source: cached, url: url), + source: cached, + notModified: true), + _ => const FetchNetworkError(), + }; + } + return switch ((sources[url], cachedSource)) { + (final String fresh, _) => _parse(url, fresh), + (null, final String cached) => _parse(url, cached), _ => const FetchNetworkError(), }; } diff --git a/app/test/fake_protocol_server.dart b/app/test/fake_protocol_server.dart new file mode 100644 index 0000000..d3eee2e --- /dev/null +++ b/app/test/fake_protocol_server.dart @@ -0,0 +1,93 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +/// Test doubles speaking the two protocols, so handler- and controller-level +/// tests can exercise the real fetch path against an in-process server. + +/// A Gemini server answering every request from [respond], so a test can say +/// exactly what the client must cope with. [respond] gets the request line +/// and writes the raw response, status line and body together. +class FakeGeminiServer { + FakeGeminiServer(this.certName, {this.clientCertName}); + + final String certName; + + /// The certificate this server asks the client for, the way a Gemini site + /// that wants an identity does. Dart's TLS verifies a client certificate + /// against the server's trust, so the test fixture is trusted by name. + final String? clientCertName; + late final int port; + late final SecureServerSocket server; + + Future start() async { + final context = SecurityContext() + ..useCertificateChain('test/fixtures/$certName-cert.pem') + ..usePrivateKey('test/fixtures/$certName-key.pem'); + if (clientCertName != null) { + context.setTrustedCertificates('test/fixtures/$clientCertName-cert.pem'); + } + server = await SecureServerSocket.bind('localhost', 0, context, + requestClientCertificate: clientCertName != null); + port = server.port; + } + + StreamSubscription answer(FutureOr Function(String, SecureSocket) respond) { + return server.listen((socket) async { + final line = await socket + .map(utf8.decode) + .transform(const LineSplitter()) + .first + .timeout(const Duration(seconds: 5)); + await respond(line, socket); + socket.destroy(); + }); + } + + Future close() => server.close(); +} + +/// A Spartan server answering every request from [respond], which gets the +/// request line and any uploaded bytes, and writes the raw response. +class FakeSpartanServer { + late final int port; + late final ServerSocket server; + + Future start() async { + server = await ServerSocket.bind('localhost', 0); + port = server.port; + } + + StreamSubscription answer(FutureOr Function(String, List, Socket) respond) { + return server.listen((socket) async { + final buffer = BytesBuilder(copy: false); + var lineEnd = -1; + await for (final chunk in socket) { + buffer.add(chunk); + final bytes = buffer.toBytes(); + if (lineEnd < 0) lineEnd = _indexOfCrlf(bytes); + if (lineEnd < 0) continue; + final line = utf8.decode(bytes.sublist(0, lineEnd)); + final length = int.parse(line.split(' ').last); + // Answer as soon as the line and any upload it announces are here; + // the client keeps the connection open waiting for the response. + if (bytes.length - lineEnd - 2 >= length) { + final upload = bytes.sublist(lineEnd + 2, lineEnd + 2 + length); + await respond(line, upload, socket); + socket.destroy(); + return; + } + } + }); + } + + Future close() => server.close(); +} + +int _indexOfCrlf(List bytes) { + for (var i = 0; i + 1 < bytes.length; i++) { + if (bytes[i] == 0x0d && bytes[i + 1] == 0x0a) return i; + } + return -1; +} diff --git a/app/test/fetch_actor_test.dart b/app/test/fetch_actor_test.dart index 5da3e5f..0558150 100644 --- a/app/test/fetch_actor_test.dart +++ b/app/test/fetch_actor_test.dart @@ -1,7 +1,14 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + import 'package:actors/actors.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mews/application/actors/fetch_actor.dart'; +import 'package:mews/mews/document.dart'; + +import 'fake_protocol_server.dart'; const page = ''' @@ -11,6 +18,8 @@ const page = ''' '''; +const gemtext = '# Title\n\nSome text.\n'; + void main() { test('ParseSource parses without touching the network', () async { final actor = Actor.create(FetchHandler.new); @@ -36,4 +45,166 @@ void main() { expect(result, isA()); expect((result as FetchParseError).source, '

nope'); }); + + // The protocol transports are exercised through the same handler a fetch + // uses, against in-process servers, so these tests cover the whole path + // from command to page. + group('protocol dispatch', () { + final handler = FetchHandler(); + StreamSubscription? spartanAnswers; + StreamSubscription? geminiAnswers; + FakeSpartanServer? spartan; + FakeGeminiServer? geminiServer; + + tearDown(() async { + await spartanAnswers?.cancel(); + await geminiAnswers?.cancel(); + await spartan?.close(); + await geminiServer?.close(); + }); + + test('a spartan page becomes a gemtext page', () async { + final server = FakeSpartanServer(); + await server.start(); + spartan = server; + spartanAnswers = server + .answer((line, upload, socket) => socket.add(utf8.encode('2 text/gemini\r\n$gemtext'))); + + final result = await handler.handle(FetchPage(url: 'spartan://localhost:${server.port}/')); + + expect(result, isA()); + final ok = result as FetchPageOk; + expect(ok.page.title, 'Title'); + expect(ok.page.isMews, isTrue); + expect(ok.page.body.first, isA()); + expect(ok.source, gemtext); + }); + + test('a spartan error status carries its message', () async { + final server = FakeSpartanServer(); + await server.start(); + spartan = server; + spartanAnswers = server + .answer((line, upload, socket) => socket.add(utf8.encode('4 Nothing lives here\r\n'))); + + final result = + await handler.handle(FetchPage(url: 'spartan://localhost:${server.port}/gone')); + + expect(result, isA()); + expect((result as FetchProtocolError).message, 'Nothing lives here'); + }); + + test('a gemini input request becomes a one-field form', () async { + final server = FakeGeminiServer('gemini-test-a'); + await server.start(); + geminiServer = server; + geminiAnswers = + server.answer((line, socket) => socket.add(utf8.encode('10 Enter a word\r\n'))); + + final result = + await handler.handle(FetchPage(url: 'gemini://localhost:${server.port}/ask')); + + expect(result, isA()); + final form = (result as FetchPageOk).page.body.single as FormBlock; + final field = form.fields.single as MewsTextField; + expect(field.name, 'query'); + expect(field.password, isFalse); + expect(field.label, 'Enter a word'); + }); + + test('a sensitive gemini input asks quietly', () async { + final server = FakeGeminiServer('gemini-test-a'); + await server.start(); + geminiServer = server; + geminiAnswers = server.answer((line, socket) => socket.add(utf8.encode('11 A secret\r\n'))); + + final result = + await handler.handle(FetchPage(url: 'gemini://localhost:${server.port}/ask')); + + final form = (result as FetchPageOk).page.body.single as FormBlock; + expect((form.fields.single as MewsTextField).password, isTrue); + }); + + test('the first visit to a gemini host reports a pin to save', () async { + final server = FakeGeminiServer('gemini-test-a'); + await server.start(); + geminiServer = server; + geminiAnswers = server + .answer((line, socket) => socket.add(utf8.encode('20 text/gemini\r\n$gemtext'))); + + final result = await handler.handle(FetchPage(url: 'gemini://localhost:${server.port}/')); + + final ok = result as FetchPageOk; + expect(ok.pinUpdates, hasLength(1)); + expect(ok.pinUpdates.single.host, 'localhost'); + expect(ok.pinUpdates.single.port, server.port); + }); + + test('a gemini site asking for a certificate gets the bound identity', () async { + final server = FakeGeminiServer('gemini-test-a', clientCertName: 'client-test'); + await server.start(); + geminiServer = server; + geminiAnswers = server.answer((line, socket) { + // The server asks but never checks: an identity is offered or not. + if (socket.peerCertificate == null) { + socket.add(utf8.encode('60 Who are you\r\n')); + } else { + socket.add(utf8.encode('20 text/gemini\r\n$gemtext')); + } + }); + + // Without an identity, the request comes back to the reader as a + // choice to make. + final asked = + await handler.handle(FetchPage(url: 'gemini://localhost:${server.port}/')); + expect(asked, isA()); + expect((asked as FetchClientCertificateRequired).status, 60); + + // With the identity, the page arrives wearing it. + final cert = File('test/fixtures/client-test-cert.pem').readAsStringSync(); + final key = File('test/fixtures/client-test-key.pem').readAsStringSync(); + final ok = await handler.handle(FetchPage( + url: 'gemini://localhost:${server.port}/', clientCert: cert, clientKey: key)); + expect(ok, isA()); + expect((ok as FetchPageOk).page.title, 'Title'); + }); + + test('a capsule image arrives as bytes', () async { + final server = FakeSpartanServer(); + await server.start(); + spartan = server; + final png = base64.decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='); + spartanAnswers = server.answer((line, upload, socket) => socket.add([ + ...utf8.encode('2 image/png\r\n'), + ...png, + ])); + + final result = await handler + .handle(FetchBytes(url: 'spartan://localhost:${server.port}/dot.png')); + + expect(result, isA()); + expect((result as FetchBytesOk).data, png); + }); + + test('a gemini form answers as a query string', () async { + final server = FakeGeminiServer('gemini-test-a'); + await server.start(); + geminiServer = server; + var asked = ''; + geminiAnswers = server.answer((line, socket) { + asked = line; + socket.add(utf8.encode('20 text/gemini\r\n$gemtext')); + }); + + final result = await handler.handle(SubmitForm( + url: 'gemini://localhost:${server.port}/ask', + method: 'get', + fields: {'query': 'phones'})); + + // The fake reads whole lines, so the CRLF is already stripped. + expect(asked, 'gemini://localhost:${server.port}/ask?query=phones'); + expect(result, isA()); + }); + }); } diff --git a/app/test/fixtures/client-test-cert.pem b/app/test/fixtures/client-test-cert.pem new file mode 100644 index 0000000..044974c --- /dev/null +++ b/app/test/fixtures/client-test-cert.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICsjCCAZqgAwIBAgIBWjANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDEwZyZWFk +ZXIwHhcNMjYxMDExMTI0MjE1WhcNMzYxMDA4MTI0MjE1WjARMQ8wDQYDVQQDEwZy +ZWFkZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCPWGGLKgFsrVVJ +lB4Xnk2YqiWeJ+KgDr2wikdIJ6kA0D6pM0+fnDCQhJAztsLQ6XMEdOBpC8gB8Vrj +o2Zshg4BD45HCb848mYgwjlCR+oCmaRBV4HhuOZk7JNOS/VHCINRpN9WPSr2UDRs +aXKvYosuz92b3g537N2Pp3x2guCGPQ8svIpv0xmF97IR0cX+1VTQ1fvpwu25CxPl +xnXlHtX5Dif7mNhb1H9gkx1/yamSpVaeH9mAX/4DmJmFOY95oUybK+yIqSwIVH2g +EMuTtqxaMMlph7faodJD6vDb1g5yNUfwDG/MVtHRgPx+47FTTd3wG8t+4Blsytpj +q0oI51vLAgMBAAGjFTATMBEGA1UdEQQKMAiCBnJlYWRlcjANBgkqhkiG9w0BAQsF +AAOCAQEAgn0/ifmoPo8ecKzpoHuYXRso9M3t/+rmvFTRri+NBSBzehIXdF46Yynw +imqEAZpr5eRIIIE/0J1///LbVcRMe0KOwdlJrM+AZOS6CtHuxLTspvYcLLjRPV4B +/asZnTEZMgZTX9kbisiQ9sFW9qekqvDIDM3Eu4/AYpA+Khbmp+O1+1r6dNOESIQl +YxtnbRxVLvNbxXxNxbAH7bXLixW3+9s3LaZ3mfQDwU3rr/yzctFk3W4b7pYKuWyQ +CSOYF7CF15ClflIs9jsejNrL3x6pwkJRRmAZYRY7nDdzF0K5LawD8jtLV9cwqOr4 +LEE9eq3AfaIN8VldEMmt/WohrwYtdQ== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/app/test/fixtures/client-test-key.pem b/app/test/fixtures/client-test-key.pem new file mode 100644 index 0000000..b14622a --- /dev/null +++ b/app/test/fixtures/client-test-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCPWGGLKgFsrVVJ +lB4Xnk2YqiWeJ+KgDr2wikdIJ6kA0D6pM0+fnDCQhJAztsLQ6XMEdOBpC8gB8Vrj +o2Zshg4BD45HCb848mYgwjlCR+oCmaRBV4HhuOZk7JNOS/VHCINRpN9WPSr2UDRs +aXKvYosuz92b3g537N2Pp3x2guCGPQ8svIpv0xmF97IR0cX+1VTQ1fvpwu25CxPl +xnXlHtX5Dif7mNhb1H9gkx1/yamSpVaeH9mAX/4DmJmFOY95oUybK+yIqSwIVH2g +EMuTtqxaMMlph7faodJD6vDb1g5yNUfwDG/MVtHRgPx+47FTTd3wG8t+4Blsytpj +q0oI51vLAgMBAAECggEAFGBX3VRnR2+MRB1N+cWC+kSVfFnnGSF/TW0anOMmXvs7 +LQoiM2ocbS6q4O8F2nSU6PlEPSpzj/MjHGDJ2ntj2jq/2tFD4XBznAx/3zAhz6TP +eIpb6B5EgewvNF4Py70mDaEGLlgbl0/nY9HJf6zeZBUgR1vVaq69AAKq6p3IFMy5 +JzuL9kgxjidqVrGYIJ8Vp/FA/WVFLjsUnMJNhBtM4Ldc+nxUiq/9PQS1+E6KUr9+ +Q+F1PlGQpX71Txwnpnkb/41hjh+EEUiXTHXT+Xlw1toIiC8hk2fqCp2/pxyKd2EQ +PJHB+afLcFO8heZi4r9owqLXYDZeyGPtCWhonkPjwQKBgQDzbGkdklJ6LrLzts/m +dp+o6Bg7dFDq1HZA/F+iR/rCJKfnv1Nw9odEYA/bfQuNf9Tue71Ze33WXGzFXb4H +4Od4IJCXrMn7leZ8OaLIaIHZbxhvLU2CItfanMOyhh02irUDh4t0mdQ7Om3SetCR +NxiaKUL6J895F/EnV1l3BGUZuQKBgQCWwE6ARULaTXWIqEgkuEQCHKcVPjRGue0C +nugjWTBcfDz8JSb7s+D2fsFjukrXx1wx9jSF9feeD5rE7bwyHiVfSae4ydDW85DV +Z2NWnbPTdbu1S/R5I1ut8CUXutd3njLJCO4PcDxR/2j8rz9mpYsDv35Msc9AoGZb +E6T6EzlTowKBgQDOPGpukHrqY8hf7eIEizpIG6P0M18fJjSuaGQKFNviQrYzBYmH +aoc+KfklZ7MzBjv8db/+IT+DLzlEmmCXQjg6MbTvnIhgbCcj1p0RGGHzO0nDX+mM +OUMSR8ER7sivXiNwwxUnC/Gg4KcNLdqpPbnNWuoREUuZd+xr/Xih+opBmQKBgG9d +NSCxCcf2/OKtXk4yCZt9yzh4MWomI56SAW9LjdxkszCZyYibw46U6QxhLLTmw4bt +GLP1lq0fXfgon6RufLE92jc6b5q7Z70emVX9PkDycm5l4/4xTqLaCqzTDCWDqoS0 +ohipkwYIJmi0laOgm2dzaTwDBtX5KGQTyQYseWb9AoGAY4ctZu3vHl2Pbg2zl53E +lD/n5zSPBRjfbZKJVfiWvC2+ut5YX7dWNKiVmurS9XPRQCTcujOYNxjbzB+t5Bcm +AQpSnwKe36wWyrH3YZkGmHxsAtxi1tUQVZxTX13NfhqYWAyxTxIrIUwXwabfTH7G +CB8KWfIABVIG4nWJDE/kRIQ= +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/app/test/fixtures/gemini-test-a-cert.pem b/app/test/fixtures/gemini-test-a-cert.pem new file mode 100644 index 0000000..0cf90b7 --- /dev/null +++ b/app/test/fixtures/gemini-test-a-cert.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICxjCCAa6gAwIBAgIBATANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDEwlsb2Nh +bGhvc3QwHhcNMjYxMDExMTIxMjI1WhcNMzYxMDA4MTIxMjI1WjAUMRIwEAYDVQQD +Ewlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDBNxVR +zQ0tgS4HmmpbsOFF4n0wip6fZKolD7Kif1vKKuzj9/+3cIiChGixmPNu4WtcIuf6 +8HvLDBcp/uoDDroALe6Y9eoAsx2ibHGFuujOhTT/fAe87GYqIMTttSuOeP2VGauT +hcHiQIB25UB7ejUUPtgk/+9b/dQpZOrFonb2FjAASy3Sxp8uhflz/D42yz/8Bes0 +0Z3ZihZeaJTTWX3fPmHbK9X5ZjjbtpIbiE+leBWQ1Kt5zuucq5tERBSDEXSw16Wk +vKubKe+t249L1QsCIg4t11v+BbY2SuvT9K5uNatxAEeNCVGWdghwQ95XaG9XrybM +05rSPGPylZogSSXNAgMBAAGjIzAhMB8GA1UdEQQYMBaCCWxvY2FsaG9zdIIJMTI3 +LjAuMC4xMA0GCSqGSIb3DQEBCwUAA4IBAQAySnYV9S5ARkwXfSCYPC9m8nmDnaIa +5iqCMmZRdGVTj/jR653s3QJZMkYSaN7UnALxTgdb8pNASgca+HuM5PBhxrX2wmI+ +4ocMwbdFrPPEi5gpIBZs6acGzX9KtpOOOOaNcz0+AsbcvyYFZLxVcdiFkcbBL1HM +jw6rqAxTvjyhSJMGcf/Es3SL/WffF3tDmumOyhN4BVgzKoQyMF+Ei0Pija+yoGrN +pyQJm0RYDEzYiw7DGUMSC8BoTj5yIUETWvNkRK78WDu1Kb5Fq3RqYAasd/wRKEfX +4dsZmmoH35tfGI0xMc2GnnMC8c7g7p/dMJdIOiupcg6/1sgqGTH5Tvb9 +-----END CERTIFICATE----- \ No newline at end of file diff --git a/app/test/fixtures/gemini-test-a-key.pem b/app/test/fixtures/gemini-test-a-key.pem new file mode 100644 index 0000000..d50d773 --- /dev/null +++ b/app/test/fixtures/gemini-test-a-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDBNxVRzQ0tgS4H +mmpbsOFF4n0wip6fZKolD7Kif1vKKuzj9/+3cIiChGixmPNu4WtcIuf68HvLDBcp +/uoDDroALe6Y9eoAsx2ibHGFuujOhTT/fAe87GYqIMTttSuOeP2VGauThcHiQIB2 +5UB7ejUUPtgk/+9b/dQpZOrFonb2FjAASy3Sxp8uhflz/D42yz/8Bes00Z3ZihZe +aJTTWX3fPmHbK9X5ZjjbtpIbiE+leBWQ1Kt5zuucq5tERBSDEXSw16WkvKubKe+t +249L1QsCIg4t11v+BbY2SuvT9K5uNatxAEeNCVGWdghwQ95XaG9XrybM05rSPGPy +lZogSSXNAgMBAAECggEACFNfT88OKk/Os/UrcBgHHQPNcTIH88Zaro4mnGnwgp2m +54vgMP9gYn9iI4T7uGNuROIcLOv/6Zfzx5N8cfVIfol8JosyMiWFuWIC4khTj9Kd +ngSyMKnfHSGcWsHbxp/gDXS1t/2jhSRSZM0sIiX5841hsvZ0Ufp2EfKPS0Tc622m +KRgN+EfM9cLuknon7L3YcK2dYhMp1ZslzDYkIjU97ydHxtZ5Xn0Zd+HGFueaWzon +zP94nx31wRSbzGF1nQ+7ESbkB2pGtP+xRva/sAlXUTRDLV3Krr5paqLWMU+law+j +PeZay5aH0go7yfLuRsxavQ/A0xHw0Tl9PoL0ikZb4QKBgQDer9OzsddE1pkTq7HR +ttX3Xg2RQoDBgFVDtrbQOm+RUOqw4NNjJVah8zx2ZSwc75PMR4WcjgLOl7JwH7Eg +3eFLFknJbGaFvkoMhKLmA6PnXFs7rX7cB3uusf2VCP5oFXiRltwOY6XxfYHyVGTw +HUfVx1eVUy0LFPNKX/fKEhWIewKBgQDeHpa+/lOusGk9xJlBFkd891j6+MsROB7C +CIQMXJiv9wKQg5G4uV3qlMiddsApwFxlpXoPxjcA0rgureNFAM4/R7PTq8yHG2NO +nUDCxkkhPb8sR7D4qZFEVvQQEjqPWQFsv+OdykPh29lzSDmPyu5rv0FHP2IKA2wM +hNHAWhYMVwKBgHSKXCa8ucZC8NXEk0kHgK72v+73eN7LxNOtObt7+Yt5Z3muD7Lh +hZc37rqfdBiooRbVj21BPOLVrQLU8yQaOp/LSE5rtrPjxGkMuRvbZ9nbEbwHrk6E +dAwXBnkKvenG4tR+uSemqt/ZKTqjRDgNety8eG+jmf0kt7aCxvEvyaklAoGBAIjd +8meoEWqAG6lK/mBD2TMguvyS15Y5rYJEexGe8Rrkqvjfiah42G0W1vNoXAqeFJEx +wR/9NKFWNtXUokk/sNb/WMiigPnzn2KutVwPkLph8O3ZEyx2WCPzLJrGkL0JzD31 +k80D+IYw0TIfHBEFq5pLFG9RmBixeR/Rq+nJ2PIrAoGBANbGlg1YZHZloev+nhOO +0TiaS0GwlnHAvss228m+V2rXkX1niH6JbVCDIQ+XxzG25Tj+UFnoSdBTnpajdZzX +5IPUenRh6wTOxKPjHj7Zvhwxx+RL9xzwudBBPC3jTJLqruTg1cw24ux7b+LDO0wG +pYXQqTjqhtdw4XTH10JQdAIj +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/app/test/fixtures/gemini-test-b-cert.pem b/app/test/fixtures/gemini-test-b-cert.pem new file mode 100644 index 0000000..5318f61 --- /dev/null +++ b/app/test/fixtures/gemini-test-b-cert.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICxjCCAa6gAwIBAgIBAjANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDEwlsb2Nh +bGhvc3QwHhcNMjYxMDExMTIxMjI1WhcNMzYxMDA4MTIxMjI1WjAUMRIwEAYDVQQD +Ewlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCQPvJZ +SEze1FQQLOzsRDMmVnRttzrLC7MxR8kVk4DMrN2Dub6/1siyn74oQI3IMYOV2JRn +MC6S5ejy0y/5F3eeZ/n1xgiUOuWi8Rg/tS74WweLY/dcn5cVhibrSmEBJtYxt0KO +3t2s6A+haK8JKztm8V7wuSHbdkjpCCMoJj175SLuCyiF0iwD80mAM4WG0OmVIXtX +lyJ/5DwTBlb0XQMXBhmRe5v95m9UanxYef9aG03LPm8pjl2EWDEzK/25mNSI5eGv +AnniRhxHtp0sXoAi6oN0sTX3eCAru2/5WdjpOan6YLsXIsRmYizDevTX/tiB18al +152YJ0Bor4QI0xARAgMBAAGjIzAhMB8GA1UdEQQYMBaCCWxvY2FsaG9zdIIJMTI3 +LjAuMC4xMA0GCSqGSIb3DQEBCwUAA4IBAQCFnr7gLYsnjraD8NRtguLEElJtTKIe +L8KW9Inil43CxJ5BF+p1IcExnZmcedRljGVES7tJ8sZDX5/VnUhErkjoy1uZjAIZ +3VVFXgKkH170HsSQv+SnRsUr/vfcqvI2AKr2Dapy+L4ftUYR9+0uXmSRXKTP2kQK +5wd2B2vjX4DMRrEiClQ2R4+bbut7bRokIr4X6NXVoEBuAZWYTGQK5Dydhg4m/t0o +NE8VC0qB2xhHxIhU5Q9PSpHuL8KJr0zJ++53X+LHfhy2fgN+ia/cqLaYszqbvQDQ +8lRGVUmpvQ+nYpvgYMmluEHJtbFGnSfvNBXsJGP9EqwN6ZiQBzNwWmRE +-----END CERTIFICATE----- \ No newline at end of file diff --git a/app/test/fixtures/gemini-test-b-key.pem b/app/test/fixtures/gemini-test-b-key.pem new file mode 100644 index 0000000..fe180c3 --- /dev/null +++ b/app/test/fixtures/gemini-test-b-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCQPvJZSEze1FQQ +LOzsRDMmVnRttzrLC7MxR8kVk4DMrN2Dub6/1siyn74oQI3IMYOV2JRnMC6S5ejy +0y/5F3eeZ/n1xgiUOuWi8Rg/tS74WweLY/dcn5cVhibrSmEBJtYxt0KO3t2s6A+h +aK8JKztm8V7wuSHbdkjpCCMoJj175SLuCyiF0iwD80mAM4WG0OmVIXtXlyJ/5DwT +Blb0XQMXBhmRe5v95m9UanxYef9aG03LPm8pjl2EWDEzK/25mNSI5eGvAnniRhxH +tp0sXoAi6oN0sTX3eCAru2/5WdjpOan6YLsXIsRmYizDevTX/tiB18al152YJ0Bo +r4QI0xARAgMBAAECggEADy2QcrSY4hmVft47T+EdQLt4JNNS7GqewGC9V/fdZawK +4wF4gNco5yLH75RbVuVe4zWW6AS54dsvV93A/oTpoVFvi9z6qh5Ia4zS8GEwQBHU +CWbLcIOacjlbfwakjmp3v2N5LW9mA/unKWTKhjx2U66yPsI8PcuEgIDKsj7T2WVq +hqT2slp0sB96y6HyIo56Wwi/DFp5Jm05n2+DqaI9OzdYsvF5XgErLRENmWmTyd8/ +N/Z/VhrJZXzmTx08fT1FoMjcyEr2km8SafX/U7TH9fV1SWqzNi/wHH0i/664IEkF +iiwDD9ArSu0PxoQ9g+IDOsS4PUifyH3ZwkNTUmvpAQKBgQDAqeGE2iWEdNFS8nZF +z76TArrqcuJwVxAyEfxrznilamu2/L0DEE9WVfGFNuenWl8mD6aZ4mmH7dHpdxH1 +pNem+KelrKYyADTREDRWIotb8/xnbRpz3OmVbbMxf/XU+4qREvg9koCiXwD0Jzb1 +eF+V6dN13EQAmRKrosnITixWnQKBgQC/qlgRSHBnBJVhpjaASlpSp6sXiOGiFsXI +MpfDAzkMZSgyroBeJQg0Yc4ZyWXmbFF/X1DhSIzMEwYjppZVgH6ME5Di3wditU96 +/iz+zt6Wa5xRh0rbBHaMqChWWdi8sHZ0QaI1NYQFs1H5hFubSRGhv4SAOv4K1sdI +JaJoXD0rBQKBgDMFCbaPuj2wXbpUgOON1w7BO1RYpgRP2fcYcjruS16qpumnVjpY +eFoJmlLHrmLP+MBwhpLbtUyMuvjTUks1ovIRiPvDk6NMELCIVQO2VCc+nkmZR9cE +2kp66WgjVatYDWLLnjdX836MIAmH1PL+NHnTmO7uLIfoAVy4WC0WHoG1AoGBAIqh +wJ+HoN2ywAXofmVe4mQEqMkWFfXyuM4sYf8JFrtzc5btByaRbJTO5Y9bUBxR3sTd +gZfhrQ1y+S7SMMzZay8Qe+zNWv83m6yfqpYHtZJhdai2zhsK6GCmxL5osE8S6CsA +yj3oXnztDm8HIGd6e4leCI3jnZrjbTMvHYCUrx2tAoGBAK/8HO7FMu14z16C9yv9 +L1Bxi51n7xe0llojR1nv+UzKE1BaG3u9Ni3kNiJGGZ21B4TS3nlWS6D3CZOMsH10 +t5fqOCKdGY1eUxRu0qKa2jMn/lbdszevsDFsry++6xf4ZgcWB9++4FJupchf2Jet +U8bDDYMHS3yhAw/hOX8Q/Vy4 +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/app/test/gemini_test.dart b/app/test/gemini_test.dart new file mode 100644 index 0000000..57993a1 --- /dev/null +++ b/app/test/gemini_test.dart @@ -0,0 +1,163 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'fake_protocol_server.dart'; + +import 'package:mews/protocols/gemini.dart'; +import 'package:mews/protocols/tofu.dart'; + +String fixtureFingerprint(String certName) { + final pem = File('test/fixtures/$certName-cert.pem').readAsStringSync(); + final body = pem + .replaceAll(RegExp(r'-----(BEGIN|END) CERTIFICATE-----'), '') + .replaceAll(RegExp(r'\s'), ''); + return sha256.convert(base64.decode(body)).toString(); +} + +Future open(String path, FakeGeminiServer server) => + Future.value(Uri.parse('gemini://localhost:${server.port}$path')); + +void main() { + late FakeGeminiServer server; + late StreamSubscription answers; + final client = GeminiClient(timeout: const Duration(seconds: 5)); + + tearDown(() async { + await answers.cancel(); + await server.close(); + }); + + test('a success answers with status, meta and body', () async { + server = FakeGeminiServer('gemini-test-a'); + await server.start(); + var requested = ''; + answers = server.answer((line, socket) async { + requested = line; + socket.add(utf8.encode('20 text/gemini\r\nhello')); + }); + + final response = await client.fetch(await open('/', server)); + + expect(response.status, 20); + expect(response.meta, 'text/gemini'); + expect(utf8.decode(response.body!), 'hello'); + expect(requested, 'gemini://localhost:${server.port}/'); + }); + + test('the first visit pins the presented certificate', () async { + server = FakeGeminiServer('gemini-test-a'); + await server.start(); + answers = server.answer((line, socket) => socket.add(utf8.encode('20 text/gemini\r\n'))); + + final response = await client.fetch(await open('/', server)); + + expect(response.pinUpdates, hasLength(1)); + final update = response.pinUpdates.single; + expect(update.host, 'localhost'); + expect(update.port, server.port); + expect(update.pin.fingerprint, fixtureFingerprint('gemini-test-a')); + }); + + test('a matching pin is left alone', () async { + server = FakeGeminiServer('gemini-test-a'); + await server.start(); + answers = server.answer((line, socket) => socket.add(utf8.encode('20 text/gemini\r\n'))); + final pins = { + 'localhost:${server.port}': + TofuPin(fingerprint: fixtureFingerprint('gemini-test-a'), expires: DateTime(2100)), + }; + + final response = await client.fetch(await open('/', server), pins: pins); + + expect(response.pinUpdates, isEmpty); + }); + + test('a changed certificate is refused while the pin is valid', () async { + server = FakeGeminiServer('gemini-test-b'); + await server.start(); + answers = server.answer((line, socket) => socket.add(utf8.encode('20 text/gemini\r\n'))); + final pins = { + 'localhost:${server.port}': + TofuPin(fingerprint: fixtureFingerprint('gemini-test-a'), expires: DateTime(2100)), + }; + + await expectLater( + client.fetch(await open('/', server), pins: pins), + throwsA(isA()), + ); + }); + + test('a changed certificate renews an expired pin', () async { + server = FakeGeminiServer('gemini-test-b'); + await server.start(); + answers = server.answer((line, socket) => socket.add(utf8.encode('20 text/gemini\r\n'))); + final pins = { + 'localhost:${server.port}': + TofuPin(fingerprint: fixtureFingerprint('gemini-test-a'), expires: DateTime(2020)), + }; + + final response = await client.fetch(await open('/', server), pins: pins); + + expect(response.pinUpdates.single.pin.fingerprint, fixtureFingerprint('gemini-test-b')); + }); + + test('a redirect is followed to the final address', () async { + server = FakeGeminiServer('gemini-test-a'); + await server.start(); + final requests = []; + answers = server.answer((line, socket) async { + requests.add(line); + if (line.endsWith('/')) { + socket.add(utf8.encode('30 ${await open('/moved.gmi', server)}\r\n')); + } else { + socket.add(utf8.encode('20 text/gemini\r\narrived')); + } + }); + + final response = await client.fetch(await open('/', server)); + + expect(response.status, 20); + expect(utf8.decode(response.body!), 'arrived'); + expect(response.url.path, '/moved.gmi'); + expect(requests, hasLength(2)); + }); + + test('a redirect loop gives up after five hops', () async { + server = FakeGeminiServer('gemini-test-a'); + await server.start(); + final requests = []; + answers = server.answer((line, socket) async { + requests.add(line); + socket.add(utf8.encode('30 ${await open('/', server)}\r\n')); + }); + + await expectLater( + client.fetch(await open('/', server)), + throwsA(isA()), + ); + expect(requests, hasLength(6)); + }); + + test('a redirect away from gemini is returned rather than followed', () async { + server = FakeGeminiServer('gemini-test-a'); + await server.start(); + answers = server.answer((line, socket) => + socket.add(utf8.encode('30 https://example.com/elsewhere\r\n'))); + + final response = await client.fetch(await open('/', server)); + + expect(response.isRedirect, isTrue); + expect(response.meta, 'https://example.com/elsewhere'); + }); + + test('an unreachable host is one failure', () async { + // A port with nothing listening on it, on a host that needs no DNS. + final nowhere = Uri.parse('gemini://localhost:1/'); + + await expectLater(client.fetch(nowhere), throwsA(isA())); + }); +} diff --git a/app/test/gemtext_test.dart b/app/test/gemtext_test.dart new file mode 100644 index 0000000..13a670a --- /dev/null +++ b/app/test/gemtext_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:mews/mews/document.dart'; +import 'package:mews/mews/gemtext.dart'; + +const page = ''' +# A capsule + +Some opening text. +A second line of it. + +## A section + +* one +* two + +=> index.gmi The index +=> retro.gmi 2026-10-08 Collecting retro phones +=> first.gmi 2026-09-21 First post + +> a quoted line +> and another + +```rust +fn main() {} +``` + +#### four hashes are text +'''; + +void main() { + test('gemtext converts to the document model', () { + final parsed = parseGemtextPage(source: page, url: 'gemini://capsule.example/index.gmi'); + + expect(parsed.url, 'gemini://capsule.example/index.gmi'); + expect(parsed.specVersion, gemtextSpecVersion); + expect(parsed.isMews, isTrue); + expect(parsed.title, 'A capsule'); + expect(parsed.direction, TextDirection.ltr); + + final headings = parsed.body + .whereType() + .map((h) => (h.level, (h.inline.single as MewsText).text)) + .toList(); + expect(headings, [ + (1, 'A capsule'), + (2, 'A section'), + ]); + + // Each text line is its own paragraph. + final paragraphs = parsed.body.whereType().length; + expect(paragraphs, 3); // Two opening lines and the four-hashes line. + + final lists = parsed.body.whereType().toList(); + expect(lists, hasLength(2)); + expect(lists.first.items, hasLength(2)); + expect( + ((lists.first.items.first.blocks.single as ParagraphBlock).inline.single as MewsText).text, + 'one'); + + // Consecutive link lines form one list, so a menu reads as a menu. + expect(lists.last.items, hasLength(3)); + + final quotes = parsed.body.whereType().toList(); + expect(quotes, hasLength(1)); + expect(quotes.first.children, hasLength(2)); + + final pre = parsed.body.whereType().single; + expect(pre.text, 'fn main() {}'); + }); + + test('dated links become entries, resolved absolute', () { + final parsed = parseGemtextPage(source: page, url: 'gemini://capsule.example/index.gmi'); + + expect( + parsed.entries.map((e) => (e.url, e.date, e.title)).toList(), + [ + ('gemini://capsule.example/retro.gmi', DateTime(2026, 10, 8), 'Collecting retro phones'), + ('gemini://capsule.example/first.gmi', DateTime(2026, 9, 21), 'First post'), + ], + ); + }); + + test('a link without a label shows its address', () { + final parsed = parseGemtextPage( + source: '=> gemini://else.example/x.gmi', url: 'gemini://capsule.example/'); + + final list = parsed.body.whereType().single; + final link = ((list.items.single.blocks.single as ParagraphBlock).inline.single as MewsLink); + expect(link.href, 'gemini://else.example/x.gmi'); + expect((link.children.single as MewsText).text, 'gemini://else.example/x.gmi'); + }); + + test('an unterminated pre block keeps its lines', () { + final parsed = parseGemtextPage( + source: '```\nstill inside', url: 'gemini://capsule.example/'); + + expect(parsed.body.whereType().single.text, 'still inside'); + }); + + test('right-to-left text is written in its own direction', () { + final parsed = parseGemtextPage( + source: '# שלום\nטקסט בעברית', url: 'gemini://capsule.example/he.gmi'); + + expect(parsed.direction, TextDirection.rtl); + expect(parsed.lang, 'he'); + expect(parsed.title, 'שלום'); + }); +} diff --git a/app/test/key_store_test.dart b/app/test/key_store_test.dart new file mode 100644 index 0000000..40cd5e8 --- /dev/null +++ b/app/test/key_store_test.dart @@ -0,0 +1,33 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:mews/application/key_store.dart'; + +void main() { + late Directory dir; + late KeyStore keys; + + setUp(() async { + dir = await Directory.systemTemp.createTemp('mews-keys'); + keys = KeyStore(() async => dir); + }); + + tearDown(() { + dir.deleteSync(recursive: true); + }); + + test('a key is stored, read back and removed', () async { + expect(await keys.read(7), isNull); + + await keys.save(7, 'KEY PEM'); + expect(await keys.read(7), 'KEY PEM'); + + // Saving again replaces, the way a renewed identity replaces its key. + await keys.save(7, 'NEW KEY PEM'); + expect(await keys.read(7), 'NEW KEY PEM'); + + await keys.delete(7); + expect(await keys.read(7), isNull); + }); +} diff --git a/app/test/reader_widget_test.dart b/app/test/reader_widget_test.dart index 436a7f8..47a9fd3 100644 --- a/app/test/reader_widget_test.dart +++ b/app/test/reader_widget_test.dart @@ -1,4 +1,6 @@ +import 'dart:convert' show base64; import 'dart:io' show File; +import 'dart:typed_data'; import 'package:drift/native.dart'; import 'package:flutter/material.dart'; @@ -24,7 +26,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(TextField), findsOneWidget); - expect(find.text('Enter a web address'), findsOneWidget); + expect(find.text('Enter an address'), findsOneWidget); // The hub opens from the left icon; no floating button remains. expect(find.byIcon(Icons.menu), findsOneWidget); expect(find.byType(FloatingActionButton), findsNothing); @@ -52,7 +54,7 @@ void main() { await tester.pumpWidget( MaterialApp( home: StringsScope( - strings: (await _strings()), + strings: await _strings(), child: MewsPageView( page: page, palette: paletteFor( @@ -77,6 +79,84 @@ void main() { await tester.pump(); expect(opened, 'https://example.com/next.html'); }); + + testWidgets('an image on a capsule arrives over the protocol', (tester) async { + // A 1x1 PNG, small enough to spell out. + final png = Uint8List.fromList(base64.decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==')); + final dot = MewsPage( + url: 'gemini://capsule.example/dot.png', + lang: 'en', + direction: TextDirection.ltr, + variant: MewsVariant.plain, + specVersion: 'gemtext', + entries: const [], + body: const [ + ImageBlock(src: 'gemini://capsule.example/dot.png', alt: 'A dot', allowed: true), + ], + ); + + Future load(String url) async => + url == 'gemini://capsule.example/dot.png' ? png : null; + + await tester.pumpWidget( + MaterialApp( + home: StringsScope( + strings: await _strings(), + child: MewsPageView( + page: dot, + palette: paletteFor( + pageVariant: MewsVariant.plain, readerVariant: 'site', dark: false), + fontFamily: 'Atkinson Hyperlegible', + fontScale: 1.0, + visitedUrls: const {}, + onLinkTap: (_) {}, + onFormSubmit: (_, _) {}, + fetchImageBytes: load, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // The image itself, decoded from the protocol's bytes. + expect(find.byType(Image), findsOneWidget); + + // An image that never arrives falls back to its alt text, the way a + // missing image on the web does. + final missing = MewsPage( + url: 'gemini://capsule.example/none.png', + lang: 'en', + direction: TextDirection.ltr, + variant: MewsVariant.plain, + specVersion: 'gemtext', + entries: const [], + body: const [ + ImageBlock(src: 'gemini://capsule.example/none.png', alt: 'A dot', allowed: true), + ], + ); + await tester.pumpWidget( + MaterialApp( + home: StringsScope( + strings: await _strings(), + child: MewsPageView( + page: missing, + palette: paletteFor( + pageVariant: MewsVariant.plain, readerVariant: 'site', dark: false), + fontFamily: 'Atkinson Hyperlegible', + fontScale: 1.0, + visitedUrls: const {}, + onLinkTap: (_) {}, + onFormSubmit: (_, _) {}, + fetchImageBytes: load, + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(Image), findsNothing); + expect(find.textContaining('could not be loaded'), findsOneWidget); + }); } Future _strings() async { diff --git a/app/test/spartan_test.dart b/app/test/spartan_test.dart new file mode 100644 index 0000000..b6dfbd4 --- /dev/null +++ b/app/test/spartan_test.dart @@ -0,0 +1,129 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'fake_protocol_server.dart'; + +import 'package:mews/protocols/spartan.dart'; + +Uri open(String path, FakeSpartanServer server) => + Uri.parse('spartan://localhost:${server.port}$path'); + +void main() { + late FakeSpartanServer server; + late StreamSubscription answers; + final client = SpartanClient(timeout: const Duration(seconds: 5)); + + tearDown(() async { + await answers.cancel(); + await server.close(); + }); + + test('a success answers with status, meta and body', () async { + server = FakeSpartanServer(); + await server.start(); + var requested = ''; + answers = server.answer((line, upload, socket) { + requested = line; + socket.add(utf8.encode('2 text/gemini\r\nhello')); + }); + + final response = await client.fetch(open('/page', server)); + + expect(response.status, 2); + expect(response.meta, 'text/gemini'); + expect(utf8.decode(response.body!), 'hello'); + expect(requested, 'localhost /page 0'); + }); + + test('an upload rides behind the request line', () async { + server = FakeSpartanServer(); + await server.start(); + var requested = ''; + var uploaded = ''; + answers = server.answer((line, upload, socket) { + requested = line; + uploaded = utf8.decode(upload); + socket.add(utf8.encode('2 text/gemini\r\nok')); + }); + + final response = + await client.fetch(open('/echo', server), upload: utf8.encode('payload')); + + expect(response.status, 2); + expect(requested, 'localhost /echo 7'); + expect(uploaded, 'payload'); + }); + + test('a query is part of the path', () async { + server = FakeSpartanServer(); + await server.start(); + var requested = ''; + answers = server.answer((line, upload, socket) { + requested = line; + socket.add(utf8.encode('2 text/gemini\r\nok')); + }); + + await client.fetch(open('/search?q=phones', server)); + + expect(requested, 'localhost /search?q=phones 0'); + }); + + test('a redirect is followed to the final address', () async { + server = FakeSpartanServer(); + await server.start(); + final requests = []; + answers = server.answer((line, upload, socket) async { + requests.add(line); + if (line.contains('/here')) { + socket.add(utf8.encode('3 ${open('/there', server)}\r\n')); + } else { + socket.add(utf8.encode('2 text/gemini\r\narrived')); + } + }); + + final response = await client.fetch(open('/here', server)); + + expect(response.status, 2); + expect(utf8.decode(response.body!), 'arrived'); + expect(response.url.path, '/there'); + expect(requests, hasLength(2)); + }); + + test('a redirect loop gives up after five hops', () async { + server = FakeSpartanServer(); + await server.start(); + final requests = []; + answers = server.answer((line, upload, socket) async { + requests.add(line); + socket.add(utf8.encode('3 ${open('/loop', server)}\r\n')); + }); + + await expectLater( + client.fetch(open('/loop', server)), + throwsA(isA()), + ); + expect(requests, hasLength(6)); + }); + + test('an error status carries its message', () async { + server = FakeSpartanServer(); + await server.start(); + answers = server.answer((line, upload, socket) => + socket.add(utf8.encode('4 Nothing lives here\r\n'))); + + final response = await client.fetch(open('/gone', server)); + + expect(response.status, 4); + expect(response.meta, 'Nothing lives here'); + expect(response.body, isNull); + }); + + test('an unreachable host is one failure', () async { + final nowhere = Uri.parse('spartan://localhost:1/'); + + await expectLater(client.fetch(nowhere), throwsA(isA())); + }); +} diff --git a/app/test/tofu_test.dart b/app/test/tofu_test.dart new file mode 100644 index 0000000..ef54ec6 --- /dev/null +++ b/app/test/tofu_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:mews/protocols/tofu.dart'; + +void main() { + test('a first visit accepts and pins what was presented', () { + final now = DateTime(2026, 10, 11); + expect(decideTofu(null, 'aa', now), TofuDecision.firstUse); + }); + + test('the pinned certificate is accepted again', () { + final now = DateTime(2026, 10, 11); + final pin = TofuPin(fingerprint: 'aa', expires: DateTime(2030)); + expect(decideTofu(pin, 'aa', now), TofuDecision.match); + }); + + test('a change is a renewal when the pin has expired', () { + final now = DateTime(2026, 10, 11); + final pin = TofuPin(fingerprint: 'aa', expires: DateTime(2020)); + expect(decideTofu(pin, 'bb', now), TofuDecision.rePin); + }); + + test('a change is refused while the pin is still valid', () { + final now = DateTime(2026, 10, 11); + final pin = TofuPin(fingerprint: 'aa', expires: DateTime(2030)); + expect(decideTofu(pin, 'bb', now), TofuDecision.refuse); + }); + + test('a pin that ends exactly now counts as expired', () { + final now = DateTime(2026, 10, 11); + final pin = TofuPin(fingerprint: 'aa', expires: now); + expect(decideTofu(pin, 'bb', now), TofuDecision.rePin); + }); + + test('a fingerprint comes from the DER encoding', () { + final pin = pinOf([1, 2, 3], expires: DateTime(2030)); + // sha256 of the three bytes, as lowercase hex. + expect( + pin.fingerprint, + '039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81', + ); + }); +}