feat: add Gemini and Spartan protocol support

This commit is contained in:
randogoth 2026-10-11 22:07:25 +03:00
parent a4a49f41d7
commit fa7aa5557c
48 changed files with 4992 additions and 161 deletions

View file

@ -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<Column> 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<Column> 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<Column> 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<void> 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<Map<String, TofuPin>> 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<void> 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<List<ClientCertificate>> allClientCertificates() =>
(select(clientCertificates)..orderBy([
(r) => OrderingTerm.asc(r.createdAt),
(r) => OrderingTerm.asc(r.id),
]))
.get();
Future<ClientCertificate?> clientCertificate(int id) =>
(select(clientCertificates)..where((r) => r.id.equals(id))).getSingleOrNull();
Future<int> addClientCertificate(String name, String certPem) =>
into(clientCertificates)
.insert(ClientCertificatesCompanion.insert(name: name, certPem: certPem));
Future<void> 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<void> 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<int?> 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<List<String>> 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<void> 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.

File diff suppressed because it is too large Load diff