2026-10-11 22:07:25 +03:00
|
|
|
import 'dart:typed_data';
|
|
|
|
|
|
2026-10-11 13:20:00 +03:00
|
|
|
import 'package:actors/actors.dart';
|
|
|
|
|
|
|
|
|
|
import 'package:mews/application/actors/fetch_actor.dart';
|
2026-10-11 13:35:19 +03:00
|
|
|
import 'package:mews/mews/atom.dart';
|
2026-10-11 22:07:25 +03:00
|
|
|
import 'package:mews/mews/gemtext.dart';
|
2026-10-11 13:20:00 +03:00
|
|
|
import 'package:mews/mews/parser.dart';
|
2026-10-11 22:07:25 +03:00
|
|
|
import 'package:mews/protocols/tofu.dart';
|
2026-10-11 13:20:00 +03:00
|
|
|
|
|
|
|
|
/// 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.
|
2026-10-11 22:07:25 +03:00
|
|
|
///
|
|
|
|
|
/// 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.
|
2026-10-11 13:20:00 +03:00
|
|
|
class FakeFetch implements Actor<FetchCommand, FetchResult> {
|
2026-10-11 22:07:25 +03:00
|
|
|
FakeFetch(this.sources,
|
|
|
|
|
{this.feeds = const {},
|
|
|
|
|
this.changedCertificates = const {},
|
|
|
|
|
this.certificateRequired = const {},
|
|
|
|
|
this.images = const {}});
|
2026-10-11 13:20:00 +03:00
|
|
|
|
|
|
|
|
final Map<String, String> sources;
|
|
|
|
|
|
2026-10-11 13:35:19 +03:00
|
|
|
/// Atom feeds by address, for sites that publish through one.
|
|
|
|
|
final Map<String, String> feeds;
|
|
|
|
|
|
2026-10-11 22:07:25 +03:00
|
|
|
/// Gemini addresses whose server certificate is not the pinned one, with
|
|
|
|
|
/// the pin of what was presented instead.
|
|
|
|
|
final Map<String, TofuPin> 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<String> certificateRequired;
|
|
|
|
|
|
|
|
|
|
/// Raw bodies by address, the way a capsule serves an image.
|
|
|
|
|
final Map<String, Uint8List> images;
|
|
|
|
|
|
2026-10-11 13:20:00 +03:00
|
|
|
@override
|
|
|
|
|
Future<FetchResult> send(FetchCommand message) async {
|
|
|
|
|
return switch (message) {
|
|
|
|
|
ParseSource(:final url, :final source) => _parse(url, source),
|
2026-10-11 22:07:25 +03:00
|
|
|
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(),
|
2026-10-11 13:20:00 +03:00
|
|
|
},
|
2026-10-11 13:35:19 +03:00
|
|
|
FetchFeed(:final url) => switch (feeds[url]) {
|
|
|
|
|
final String source =>
|
|
|
|
|
FetchFeedOk(url: url, entries: parseAtomEntries(source: source, feedUrl: url)),
|
|
|
|
|
null => const FetchNetworkError(),
|
|
|
|
|
},
|
2026-10-11 22:07:25 +03:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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<String, String> 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),
|
2026-10-11 13:20:00 +03:00
|
|
|
_ => const FetchNetworkError(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
FetchResult _parse(String url, String source) {
|
|
|
|
|
try {
|
|
|
|
|
return FetchPageOk(page: parseMewsPage(source: source, url: url), source: source);
|
|
|
|
|
} on MewsParseException catch (e) {
|
|
|
|
|
return FetchParseError(e.message, source);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<void> close() async {}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
|
|
|
}
|