feat(profile): profile pages on the account domain, and links verified by rel="me"

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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-15 20:29:30 +02:00
commit e8ddb8b950
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 17022 additions and 14 deletions

View file

@ -275,6 +275,8 @@ void main() {
address: 'alice@halkyon.eu',
displayName: 'Alice',
bio: 'Learning Japanese.',
links: ['https://alice.example', 'https://blog.example'],
verifiedLinks: ['https://alice.example'],
),
},
),
@ -289,6 +291,16 @@ void main() {
expect(find.text('Learning Japanese.'), findsOneWidget);
expect(find.text('@alice@halkyon.eu'), findsOneWidget);
// The link whose page links back to her profile is marked; the other
// isn't.
expect(find.byTooltip('Links back to this profile'), findsOneWidget);
expect(
find.descendant(
of: find.widgetWithText(ListTile, 'alice.example'),
matching: find.byTooltip('Links back to this profile'),
),
findsOneWidget,
);
});
testWidgets('a summary shows all of it, and adds it to the library as '

View file

@ -1,6 +1,6 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/chevron_field.dart';
@ -327,6 +327,160 @@ void main() {
expect(profiles.profile!.links, ['https://halkyon.app']);
},
);
group('verified links', () {
PublicProfile withLinks(List<String> links) => PublicProfile(
handle: 'raisondetredev',
address: 'raisondetredev@halkyon.eu',
links: links,
pageUrl: 'https://halkyon.eu/@raisondetredev',
);
testWidgets('the profile page to link back to shows, and its address '
'copies', (tester) async {
String? copied;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'Clipboard.setData') {
copied = (call.arguments as Map)['text'] as String?;
}
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
),
);
await showProfile(tester, FakeProfileGateway(profile: withLinks([])));
expect(
find.text('Your profile page: halkyon.eu/@raisondetredev'),
findsOneWidget,
);
await tester.tap(find.byTooltip('Copy the address'));
await tester.pumpAndSettle();
expect(copied, 'https://halkyon.eu/@raisondetredev');
expect(find.text('Address copied.'), findsOneWidget);
});
testWidgets('a link saved is checked by itself, and gets a checkmark once '
'its page links back, without a message', (tester) async {
final profiles = FakeProfileGateway(profile: withLinks([]))
..linkingBack.add('https://halkyon.app');
await showProfile(tester, profiles);
await tester.tap(find.text('Add link'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextField, 'https://…'),
'https://halkyon.app',
);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(profiles.linkChecks, 1);
expect(find.byTooltip('Links back to your profile'), findsOneWidget);
expect(find.textContaining('Checked:'), findsNothing);
});
testWidgets('Check links checks again, and says how many link back', (
tester,
) async {
final profiles = FakeProfileGateway(
profile: withLinks(['https://halkyon.app', 'https://example.org']),
)..linkingBack.add('https://example.org');
await showProfile(tester, profiles);
expect(find.byTooltip('Links back to your profile'), findsNothing);
await tester.tap(find.text('Check links'));
await tester.pumpAndSettle();
expect(
find.text('Checked: 1 of 2 links link back to your profile.'),
findsOneWidget,
);
expect(find.byTooltip('Links back to your profile'), findsOneWidget);
});
testWidgets('checking too often says so', (tester) async {
final profiles = FakeProfileGateway(
profile: withLinks(['https://halkyon.app']),
)..refuseLinkChecks = true;
await showProfile(tester, profiles);
await tester.tap(find.text('Check links'));
await tester.pumpAndSettle();
expect(
find.text('The links were checked too often. Try again in an hour.'),
findsOneWidget,
);
});
test('a check that ends after a save keeps what was saved, and adds the '
"check's answer to it", () async {
final profiles =
FakeProfileGateway(
profile: withLinks(['https://a.example']),
)
..linkingBack.add('https://a.example')
..checkWaits = Completer();
final bloc = PublicProfileBloc(ProfileRepository(profiles))
..add(const PublicProfileEventStarted());
addTearDown(bloc.close);
await expectLater(
bloc.stream,
emitsThrough(isA<PublicProfileStateReady>()),
);
bloc.add(const PublicProfileEventLinksChecked());
await expectLater(
bloc.stream,
emits(
isA<PublicProfileStateReady>().having(
(state) => state.checkingLinks,
'checking',
isTrue,
),
),
);
bloc.add(
const PublicProfileEventSaved(
displayName: 'Tizian',
bio: '',
links: ['https://a.example'],
),
);
await expectLater(
bloc.stream,
emitsThrough(
isA<PublicProfileStateReady>().having(
(state) => state.outcome,
'outcome',
PublicProfileOutcome.saved,
),
),
);
profiles.checkWaits!.complete();
await expectLater(
bloc.stream,
emits(
isA<PublicProfileStateReady>()
.having((state) => state.profile?.displayName, 'name', 'Tizian')
.having(
(state) => state.profile?.verifiedLinks,
'verified',
['https://a.example'],
),
),
);
});
});
}
/// Saves only once [release] lets each through.

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_gateway.dart';
@ -89,6 +90,15 @@ class FakeProfileGateway({
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);
@ -139,4 +149,36 @@ class FakeProfileGateway({
}
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);
}
}