A profile now has a page on the web, at the root of the account domain (https://halkyon.eu/@raisondetredev where the operator set ACCOUNT_DOMAIN, else on the instance's web server), so the address belongs to the identity rather than to Learn and stays when other services share it (ADR 0011). The page shows what the app's profile sheet shows, in English or German, without JavaScript or cookies, asks search engines not to list it, marks every link rel="me" so other sites can verify links to it, and sends a visitor who asked on another host to its own address. Caddy forwards /@… to the web server, and serves nothing else on the account domain; compose now passes ACCOUNT_DOMAIN on, which it never did. A link is verified as Mastodon verifies one: its page links back to the profile's with rel="me". profile.checkLinks fetches each link's page, at most ten times an hour, over HTTPS on port 443 and to public addresses only, connecting to the address it checked and verifying the certificate for the host, with three redirects, 1 MiB and ten seconds at most, and keeps what links back on the profile (verifiedLinks, a column of a table no device syncs). A changed link loses its check, a new handle all of them. The app shows the page's address to copy, checks a link saved by itself and on Check links, and marks verified links in the form and in others' profiles; a check answered after a newer save only adds its checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
184 lines
5 KiB
Dart
184 lines
5 KiB
Dart
import 'dart:async';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_gateway.dart';
|
|
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
|
|
|
|
/// A [ProfileGateway] in memory, on an instance whose account domain is
|
|
/// `halkyon.eu`: one profile, refusing the handles others have (whatever
|
|
/// their case) and, once its handle is fixed, any other handle.
|
|
class FakeProfileGateway({
|
|
/// The person's profile; null while they have none.
|
|
var PublicProfile? profile,
|
|
|
|
/// Handles others have.
|
|
final Set<String> taken = const {},
|
|
|
|
/// Others' profiles, by their handles.
|
|
final Map<String, PublicProfile> others = const {},
|
|
}) implements ProfileGateway {
|
|
@override
|
|
Future<PublicProfile?> byHandle(String handle) async {
|
|
if (fail case final error?) {
|
|
throw error;
|
|
}
|
|
return others[handle.toLowerCase()];
|
|
}
|
|
|
|
/// When set, every request fails with it.
|
|
Exception? fail;
|
|
|
|
@override
|
|
Future<PublicProfile?> mine() async {
|
|
if (fail case final error?) {
|
|
throw error;
|
|
}
|
|
return profile;
|
|
}
|
|
|
|
/// The handles checked, in order.
|
|
final checked = <String>[];
|
|
|
|
@override
|
|
Future<ProfileProblem?> checkHandle(String handle) async {
|
|
if (fail case final error?) {
|
|
throw error;
|
|
}
|
|
checked.add(handle);
|
|
return _problemOf(handle);
|
|
}
|
|
|
|
ProfileProblem? _problemOf(String handle) {
|
|
final current = profile;
|
|
if (!RegExp(r'^[A-Za-z0-9_]{1,30}$').hasMatch(handle)) {
|
|
return ProfileProblem.handleInvalid;
|
|
}
|
|
if (handle.toLowerCase() == 'admin') {
|
|
return ProfileProblem.handleReserved;
|
|
}
|
|
if (taken.any((other) => other.toLowerCase() == handle.toLowerCase())) {
|
|
return ProfileProblem.handleTaken;
|
|
}
|
|
if (current != null &&
|
|
current.handleFixed &&
|
|
current.handle.toLowerCase() != handle.toLowerCase()) {
|
|
return ProfileProblem.published;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Every profile saved, in order.
|
|
final saves = <PublicProfile>[];
|
|
|
|
@override
|
|
Future<PublicProfile> save({
|
|
required String handle,
|
|
required String displayName,
|
|
required String bio,
|
|
required List<String> links,
|
|
}) async {
|
|
if (fail case final error?) {
|
|
throw error;
|
|
}
|
|
final current = profile;
|
|
if (_problemOf(handle) case final problem?) {
|
|
throw ProfileRefused(problem);
|
|
}
|
|
final saved = profile = PublicProfile(
|
|
handle: handle,
|
|
address: '$handle@halkyon.eu',
|
|
displayName: displayName,
|
|
bio: bio,
|
|
links: links,
|
|
// As the instance keeps them: those of links left as they were, under
|
|
// the same handle.
|
|
verifiedLinks: [
|
|
if (current?.handle.toLowerCase() == handle.toLowerCase())
|
|
for (final link in current?.verifiedLinks ?? const <String>[])
|
|
if (links.contains(link)) link,
|
|
],
|
|
pageUrl: 'https://halkyon.eu/@$handle',
|
|
avatarUrl: current?.avatarUrl,
|
|
handleFixed: current?.handleFixed ?? false,
|
|
);
|
|
saves.add(saved);
|
|
return saved;
|
|
}
|
|
|
|
/// The avatars set, in order.
|
|
final avatars = <Uint8List>[];
|
|
|
|
@override
|
|
Future<PublicProfile> setAvatar(Uint8List png) async {
|
|
if (fail case final error?) {
|
|
throw error;
|
|
}
|
|
avatars.add(png);
|
|
final current = profile!;
|
|
return profile = PublicProfile(
|
|
handle: current.handle,
|
|
address: current.address,
|
|
displayName: current.displayName,
|
|
bio: current.bio,
|
|
links: current.links,
|
|
avatarUrl: 'https://learn.halkyon.eu/avatars/${avatars.length}.png',
|
|
handleFixed: current.handleFixed,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<PublicProfile> removeAvatar() async {
|
|
final current = profile!;
|
|
return profile = PublicProfile(
|
|
handle: current.handle,
|
|
address: current.address,
|
|
displayName: current.displayName,
|
|
bio: current.bio,
|
|
links: current.links,
|
|
handleFixed: current.handleFixed,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> remove() async {
|
|
if (fail case final error?) {
|
|
throw error;
|
|
}
|
|
if (profile?.handleFixed ?? false) {
|
|
throw ProfileRefused(ProfileProblem.published);
|
|
}
|
|
profile = null;
|
|
}
|
|
|
|
/// The links whose pages link back to the profile page.
|
|
final linkingBack = <String>{};
|
|
|
|
/// How often the links were checked.
|
|
int linkChecks = 0;
|
|
|
|
/// While set, checking the links is refused as asked too often.
|
|
bool refuseLinkChecks = false;
|
|
|
|
/// While set, a check waits for it before it answers.
|
|
Completer<void>? checkWaits;
|
|
|
|
@override
|
|
Future<PublicProfile> checkLinks() async {
|
|
if (fail case final error?) {
|
|
throw error;
|
|
}
|
|
if (refuseLinkChecks) {
|
|
throw ProfileRefused(ProfileProblem.tooManyLinkChecks);
|
|
}
|
|
linkChecks++;
|
|
// The profile as the check began, as an answer may come after a save.
|
|
final began = profile!;
|
|
await checkWaits?.future;
|
|
final verified = [
|
|
for (final link in began.links)
|
|
if (linkingBack.contains(link)) link,
|
|
];
|
|
profile = profile!.withVerifiedLinks(verified);
|
|
return began.withVerifiedLinks(verified);
|
|
}
|
|
}
|