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
48 changed files with 17022 additions and 14 deletions

File diff suppressed because one or more lines are too long

View file

@ -6,6 +6,13 @@
# `http://localhost` for the local rehearsal; `learn.halkyon.eu` on its server.
DOMAIN=http://localhost
# The domain people's handles are on (`raisondetredev@halkyon.eu`) and their profile
# pages (`https://halkyon.eu/@raisondetredev`, ADR 0011), when it isn't DOMAIN's host:
# point its DNS (A and AAAA) at this server, and Caddy serves the profile pages there,
# nothing else. Unset: DOMAIN's host. Decide before anyone follows a handle: it can
# never change once the fediverse knows it.
#ACCOUNT_DOMAIN=halkyon.eu
# Sidecars switched on beside the instance (sidecars/README.md), each by its compose file
# after the instance's own: compose.jellyfin.yaml, a Jellyfin people watch and listen to
# learn from. Unset: the instance alone.

View file

@ -1,13 +1,29 @@
# Caddy forwards to the API server, and the assistant connector's paths (ADR 0006) to
# the web server (8082), which serves nothing else. Insights (8081) doesn't run in
# Caddy forwards to the API server, and the assistant connector's paths (ADR 0006) and
# profile pages (ADR 0011) to the web server (8082), which serves nothing else. Insights (8081) doesn't run in
# production; if it is ever re-enabled, reach it through an SSH tunnel, never through
# this file.
#
# {$DOMAIN} is `learn.halkyon.eu` on its server, where Caddy obtains and renews a
# Let's Encrypt certificate by itself, and `http://localhost` for the local rehearsal.
{$DOMAIN} {
# {$ACCOUNT_SITE} is the account domain handles are on where it isn't {$DOMAIN}
# (`halkyon.eu`), whose profile pages this instance serves; empty for none.
{$DOMAIN} {$ACCOUNT_SITE} {
encode zstd gzip
# Profile pages, `/@handle`; asked for on the other host, they send the visitor to
# their own address.
@profile path /@*
handle @profile {
reverse_proxy server:8082
}
# The account domain serves profile pages alone. (An expression: a host matcher of
# an empty variable would match every host.)
@account expression `{host} == "{$ACCOUNT_SITE}"`
handle @account {
respond 404
}
# The assistant connector: the MCP endpoint, its OAuth pages, and their metadata.
@connector path /mcp /oauth/* /.well-known/oauth-protected-resource /.well-known/oauth-protected-resource/* /.well-known/oauth-authorization-server
handle @connector {

View file

@ -36,6 +36,9 @@ services:
# `learn.halkyon.eu` on its server (automatic Let's Encrypt certificate);
# `http://localhost` for the local rehearsal (plain HTTP, no certificate).
DOMAIN: ${DOMAIN:?set DOMAIN in deploy/.env}
# The account domain whose profile pages this instance serves, where it isn't
# DOMAIN; empty for none.
ACCOUNT_SITE: ${ACCOUNT_DOMAIN:-}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data # certificates: must survive restarts (rate limits)
@ -74,6 +77,8 @@ services:
SERVERPOD_PASSWORD_emailFrom: ${EMAIL_FROM:-}
# Where the source of what this instance runs is; empty: the project's.
SOURCE_URL: ${SOURCE_URL:-}
# The domain people's handles are on; empty: DOMAIN's host.
ACCOUNT_DOMAIN: ${ACCOUNT_DOMAIN:-}
# The Jellyfin server suggested to people; empty: jellyfin. on the
# instance's own domain, if one answers there; `none`: none.
JELLYFIN_URL: ${JELLYFIN_URL:-}

View file

@ -0,0 +1,62 @@
# ADR 0011: Profile pages on the account domain, and links verified by rel="me"
- **Status:** accepted
- **Date:** 2026-10-15
## Context
A public profile (ADR 0005, rule 2) holds up to four links: a person's site, their
other accounts. Anyone can write any link, so a profile can claim a site that isn't the
person's. Mastodon settles this with `rel="me"`: a link is verified when the page it
points to links back to the profile with `rel="me"`. The same works the other way:
Mastodon verifies a link to a profile page that links to the Mastodon account with
`rel="me"`. Both need a profile to have an address on the web, which until now it had
not; profiles showed only inside the app.
The developer means to run more services than Learn, such as train.halkyon.eu, sharing
one identity. Handles already belong to no single service: they are on the account domain
(`raisondetredev@halkyon.eu`), not on `learn.halkyon.eu`.
## Decision
1. **A profile's page is at the root of the account domain**
(`https://halkyon.eu/@raisondetredev`) where the operator set one (`ACCOUNT_DOMAIN`),
else on the instance's web server, as it is reached (`profilePageUrl`). The address
belongs to the identity, not to Learn: a shared identity service can serve it later
without any link or verification breaking, and federation (ADR 0005, stage 6) can
build on it. Learn serves it for now: Caddy forwards `/@…` on both its own domain and
the account domain to the web server, and the account domain serves nothing else.
Asked for on another host, a page sends the visitor to its own address (308).
2. **The page shows the profile**, as the app's profile sheet does: avatar, name, address,
bio, links. Server-rendered, English or German by the browser's language, no
JavaScript, no cookies, nothing fetched but the avatar (its Content-Security-Policy
says so), and `noindex`: people choose to be found in the app, not by search engines.
Every link is marked `rel="me"`, so other sites can verify it in turn.
3. **A link is verified when its page links back** with an `<a>` or `<link>` whose `rel`
holds `me` and whose address is the profile page's, compared without case, a trailing
slash, a query or a fragment (`linksBackTo`, read with pub.dev's `html`, the Dart
team's HTML5 parser, BSD-3). What is verified is kept on the profile row
(`verifiedLinks`), which no device syncs.
4. **Checked when asked**: the app asks after a save that brings a link not yet checked,
and when the person taps Check links (`profile.checkLinks`), at most 10 times an hour,
since each check fetches every page linked. A check stays until the link changes; a
new handle moves the page, and drops every check.
5. **Fetching stays safe** (`SafeLinkFetcher`): HTTPS on port 443 only; the host's
addresses are looked up and every one must be public (not loopback, private,
link-local, shared, unique local, multicast, documentation or reserved, nor such an
IPv4 address inside an IPv6 one), and the connection goes to the address checked,
the certificate verified for the host, so no second lookup can send it into the
instance's own networks; at most 3 redirects, each checked anew; at most 1 MiB of HTML
within 10 seconds; no proxy, no cookies; a User-Agent naming the instance.
## Consequences
- learn.halkyon.eu needs `ACCOUNT_DOMAIN=halkyon.eu` in `deploy/.env`, DNS for
halkyon.eu pointing at its server, and the deploy that brings this; until then its
handles are on `learn.halkyon.eu`, the fallback. The account domain can still change
now, as nobody follows a handle yet; studysets copied before keep the old domain in
their `location`, which only names where they came from.
- Profiles are public on the web, not only in the app. The privacy policy says so.
- The sites people link see the instance fetch their page, from its address.
- Sharing the account itself with another service (signing in to train.halkyon.eu as the
same person) is a separate decision, not taken here.

View file

@ -722,6 +722,18 @@ class EndpointProfile extends _isc.EndpointRef {
},
);
/// Checks which of your links' pages link back to your profile's page
/// with `rel="me"` (ADR 0011), and keeps the answer: the app asks after a
/// link is saved, and on Check again. At most 10 times an hour, as each
/// check fetches every page linked ([ProfileRefusal.tooManyLinkChecks]).
/// Throws [NotFoundException] while you have no profile.
_ida.Future<_ihcmr71y.Profile> checkLinks() =>
caller.callServerEndpoint<_ihcmr71y.Profile>(
'profile',
'checkLinks',
{},
);
/// Why [handle] couldn't be yours, as saving it would refuse it: not a
/// handle, reserved for the instance, someone else's whatever its case,
/// or a new one once you have published; null when it can, as your own

View file

@ -23,6 +23,8 @@ abstract class Profile
required this.displayName,
required this.bio,
required this.links,
this.verifiedLinks,
this.pageUrl,
this.avatarUrl,
required this.handleFixed,
});
@ -33,6 +35,8 @@ abstract class Profile
required String displayName,
required String bio,
required List<String> links,
List<String>? verifiedLinks,
String? pageUrl,
String? avatarUrl,
required bool handleFixed,
}) = _ProfileImpl;
@ -46,6 +50,12 @@ abstract class Profile
links: _itx5j3f5.Protocol().deserialize<List<String>>(
jsonSerialization['links'],
),
verifiedLinks: jsonSerialization['verifiedLinks'] == null
? null
: _itx5j3f5.Protocol().deserialize<List<String>>(
jsonSerialization['verifiedLinks'],
),
pageUrl: jsonSerialization['pageUrl'] as String?,
avatarUrl: jsonSerialization['avatarUrl'] as String?,
handleFixed: _isc.BoolJsonExtension.fromJson(
jsonSerialization['handleFixed'],
@ -69,6 +79,15 @@ abstract class Profile
/// Pages about the person, in their order.
List<String> links;
/// Those of [links] whose pages link back to [pageUrl] with `rel="me"`
/// (ADR 0011). Null from servers that predate it: none.
List<String>? verifiedLinks;
/// The profile's page on the web (`https://halkyon.eu/@raisondetredev`),
/// which a person's other pages link back to. Null where the instance
/// has none, and from servers that predate it.
String? pageUrl;
/// Where their avatar is, a PNG; null for none.
String? avatarUrl;
@ -84,6 +103,8 @@ abstract class Profile
String? displayName,
String? bio,
List<String>? links,
List<String>? verifiedLinks,
String? pageUrl,
String? avatarUrl,
bool? handleFixed,
});
@ -96,6 +117,8 @@ abstract class Profile
'displayName': displayName,
'bio': bio,
'links': links.toJson(),
if (verifiedLinks != null) 'verifiedLinks': verifiedLinks?.toJson(),
if (pageUrl != null) 'pageUrl': pageUrl,
if (avatarUrl != null) 'avatarUrl': avatarUrl,
'handleFixed': handleFixed,
};
@ -110,6 +133,8 @@ abstract class Profile
'displayName': displayName,
'bio': bio,
'links': links.toJson(),
if (verifiedLinks != null) 'verifiedLinks': verifiedLinks?.toJson(),
if (pageUrl != null) 'pageUrl': pageUrl,
if (avatarUrl != null) 'avatarUrl': avatarUrl,
'handleFixed': handleFixed,
};
@ -130,6 +155,8 @@ class _ProfileImpl extends Profile {
required String displayName,
required String bio,
required List<String> links,
List<String>? verifiedLinks,
String? pageUrl,
String? avatarUrl,
required bool handleFixed,
}) : super._(
@ -138,6 +165,8 @@ class _ProfileImpl extends Profile {
displayName: displayName,
bio: bio,
links: links,
verifiedLinks: verifiedLinks,
pageUrl: pageUrl,
avatarUrl: avatarUrl,
handleFixed: handleFixed,
);
@ -152,6 +181,8 @@ class _ProfileImpl extends Profile {
String? displayName,
String? bio,
List<String>? links,
Object? verifiedLinks = _Undefined,
Object? pageUrl = _Undefined,
Object? avatarUrl = _Undefined,
bool? handleFixed,
}) {
@ -161,6 +192,10 @@ class _ProfileImpl extends Profile {
displayName: displayName ?? this.displayName,
bio: bio ?? this.bio,
links: links ?? this.links.map((e0) => e0).toList(),
verifiedLinks: verifiedLinks is List<String>?
? verifiedLinks
: this.verifiedLinks?.map((e0) => e0).toList(),
pageUrl: pageUrl is String? ? pageUrl : this.pageUrl,
avatarUrl: avatarUrl is String? ? avatarUrl : this.avatarUrl,
handleFixed: handleFixed ?? this.handleFixed,
);

View file

@ -40,7 +40,10 @@ enum ProfileRefusal implements _isc.SerializableModel {
linkInvalid,
/// The avatar isn't a PNG of at most 1 MB.
avatarInvalid;
avatarInvalid,
/// The links were checked too often in the last hour.
tooManyLinkChecks;
static ProfileRefusal fromJson(String name) {
switch (name) {
@ -62,6 +65,8 @@ enum ProfileRefusal implements _isc.SerializableModel {
return ProfileRefusal.linkInvalid;
case 'avatarInvalid':
return ProfileRefusal.avatarInvalid;
case 'tooManyLinkChecks':
return ProfileRefusal.tooManyLinkChecks;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "ProfileRefusal"',

View file

@ -2695,6 +2695,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
.toList()
as T;
}
if (t == _isc.getType<List<String>?>()) {
return (data != null
? (data as List).map((e) => deserialize<String>(e)).toList()
: null)
as T;
}
if (t == List<_i7kd0wkp.ReleaseInfo>) {
return (data as List)
.map((e) => deserialize<_i7kd0wkp.ReleaseInfo>(e))

View file

@ -514,6 +514,7 @@
"publicProfileTitle": "Öffentliches Profil",
"profileSheetTitle": "Profil",
"profileSheetNotFound": "Niemand auf dieser Instanz hat diesen Handle.",
"profileSheetLinkVerified": "Verlinkt zurück auf dieses Profil",
"profileSheetUnreachable": "Das Profil konnte nicht geladen werden. Prüfe deine Verbindung.",
"publicProfileIntro": "Wie andere dich sehen, sobald du veröffentlichst: das Handle, unter dem sie dich finden, dein Name, ein paar Worte und Links. Es speichert sich beim Schreiben, sobald du dein Handle gewählt hast.",
"publicProfileAnonymousHint": "Melde dich an, um ein öffentliches Profil zu haben und zu veröffentlichen.",
@ -529,6 +530,13 @@
"publicProfileLinksHint": "Deine Website oder andere Konten, bis zu 4.",
"publicProfileLinkHint": "https://…",
"publicProfileAddLink": "Link hinzufügen",
"publicProfilePage": "Deine Profilseite: {url}",
"publicProfilePageCopy": "Adresse kopieren",
"publicProfilePageCopied": "Adresse kopiert.",
"publicProfileVerifyHint": "Ein Link bekommt ein Häkchen, sobald seine Seite mit rel=\"me\" auf deine Profilseite zurückverlinkt, wie Mastodon Links prüft.",
"publicProfileCheckLinks": "Links prüfen",
"publicProfileLinkVerified": "Verlinkt zurück auf dein Profil",
"publicProfileLinksChecked": "{count, plural, =1{Geprüft: {verified} von 1 Link verlinkt zurück auf dein Profil.} other{Geprüft: {verified} von {count} Links verlinken zurück auf dein Profil.}}",
"publicProfileRemoveLink": "Link entfernen",
"publicProfileSaving": "Wird gespeichert …",
"publicProfileSavedNow": "Gespeichert",
@ -559,6 +567,7 @@
"profileProblemTooManyLinks": "Höchstens 4 Links.",
"profileProblemLinkInvalid": "Links beginnen mit https:// oder http://.",
"profileProblemAvatarInvalid": "Dieses Bild lässt sich nicht verwenden: Wähle ein PNG-, JPEG-, WebP- oder GIF-Bild.",
"profileProblemTooManyLinkChecks": "Die Links wurden zu oft geprüft. Versuche es in einer Stunde noch einmal.",
"publicProfilePicture": "Bild",
"publicProfileChoosePicture": "Bild wählen",
"publicProfileRemovePicture": "Bild entfernen",

View file

@ -579,6 +579,7 @@
"publicProfileTitle": "Public profile",
"profileSheetTitle": "Profile",
"profileSheetNotFound": "Nobody on this instance has this handle.",
"profileSheetLinkVerified": "Links back to this profile",
"profileSheetUnreachable": "The profile couldn’t be loaded. Check your connection.",
"publicProfileIntro": "How others see you once you publish: the handle they find you by, your name, a few words and links. It saves as you write, once you have chosen your handle.",
"publicProfileAnonymousHint": "Sign in to have a public profile and publish.",
@ -594,6 +595,15 @@
"publicProfileLinksHint": "Your site or other accounts, up to 4.",
"publicProfileLinkHint": "https://…",
"publicProfileAddLink": "Add link",
"publicProfilePage": "Your profile page: {url}",
"@publicProfilePage": {"placeholders": {"url": {"type": "String"}}},
"publicProfilePageCopy": "Copy the address",
"publicProfilePageCopied": "Address copied.",
"publicProfileVerifyHint": "A link gets a checkmark once its page links back to your profile page with rel=\"me\", as Mastodon checks links.",
"publicProfileCheckLinks": "Check links",
"publicProfileLinkVerified": "Links back to your profile",
"publicProfileLinksChecked": "{count, plural, =1{Checked: {verified} of 1 link links back to your profile.} other{Checked: {verified} of {count} links link back to your profile.}}",
"@publicProfileLinksChecked": {"placeholders": {"count": {"type": "int"}, "verified": {"type": "int"}}},
"publicProfileRemoveLink": "Remove link",
"publicProfileSaving": "Saving…",
"publicProfileSavedNow": "Saved",
@ -628,6 +638,7 @@
"profileProblemTooManyLinks": "At most 4 links.",
"profileProblemLinkInvalid": "Links start with https:// or http://.",
"profileProblemAvatarInvalid": "This picture can't be used: choose a PNG, JPEG, WebP or GIF image.",
"profileProblemTooManyLinkChecks": "The links were checked too often. Try again in an hour.",
"publicProfilePicture": "Picture",
"publicProfileChoosePicture": "Choose picture",
"publicProfileRemovePicture": "Remove picture",

View file

@ -3094,6 +3094,12 @@ abstract class AppLocalizations {
/// **'Nobody on this instance has this handle.'**
String get profileSheetNotFound;
/// No description provided for @profileSheetLinkVerified.
///
/// In en, this message translates to:
/// **'Links back to this profile'**
String get profileSheetLinkVerified;
/// No description provided for @profileSheetUnreachable.
///
/// In en, this message translates to:
@ -3178,6 +3184,48 @@ abstract class AppLocalizations {
/// **'Add link'**
String get publicProfileAddLink;
/// No description provided for @publicProfilePage.
///
/// In en, this message translates to:
/// **'Your profile page: {url}'**
String publicProfilePage(String url);
/// No description provided for @publicProfilePageCopy.
///
/// In en, this message translates to:
/// **'Copy the address'**
String get publicProfilePageCopy;
/// No description provided for @publicProfilePageCopied.
///
/// In en, this message translates to:
/// **'Address copied.'**
String get publicProfilePageCopied;
/// No description provided for @publicProfileVerifyHint.
///
/// In en, this message translates to:
/// **'A link gets a checkmark once its page links back to your profile page with rel=\"me\", as Mastodon checks links.'**
String get publicProfileVerifyHint;
/// No description provided for @publicProfileCheckLinks.
///
/// In en, this message translates to:
/// **'Check links'**
String get publicProfileCheckLinks;
/// No description provided for @publicProfileLinkVerified.
///
/// In en, this message translates to:
/// **'Links back to your profile'**
String get publicProfileLinkVerified;
/// No description provided for @publicProfileLinksChecked.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Checked: {verified} of 1 link links back to your profile.} other{Checked: {verified} of {count} links link back to your profile.}}'**
String publicProfileLinksChecked(int count, int verified);
/// No description provided for @publicProfileRemoveLink.
///
/// In en, this message translates to:
@ -3352,6 +3400,12 @@ abstract class AppLocalizations {
/// **'This picture can\'t be used: choose a PNG, JPEG, WebP or GIF image.'**
String get profileProblemAvatarInvalid;
/// No description provided for @profileProblemTooManyLinkChecks.
///
/// In en, this message translates to:
/// **'The links were checked too often. Try again in an hour.'**
String get profileProblemTooManyLinkChecks;
/// No description provided for @publicProfilePicture.
///
/// In en, this message translates to:

View file

@ -1899,6 +1899,9 @@ class AppLocalizationsDe extends AppLocalizations {
String get profileSheetNotFound =>
'Niemand auf dieser Instanz hat diesen Handle.';
@override
String get profileSheetLinkVerified => 'Verlinkt zurück auf dieses Profil';
@override
String get profileSheetUnreachable =>
'Das Profil konnte nicht geladen werden. Prüfe deine Verbindung.';
@ -1949,6 +1952,39 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get publicProfileAddLink => 'Link hinzufügen';
@override
String publicProfilePage(String url) {
return 'Deine Profilseite: $url';
}
@override
String get publicProfilePageCopy => 'Adresse kopieren';
@override
String get publicProfilePageCopied => 'Adresse kopiert.';
@override
String get publicProfileVerifyHint =>
'Ein Link bekommt ein Häkchen, sobald seine Seite mit rel=\"me\" auf deine Profilseite zurückverlinkt, wie Mastodon Links prüft.';
@override
String get publicProfileCheckLinks => 'Links prüfen';
@override
String get publicProfileLinkVerified => 'Verlinkt zurück auf dein Profil';
@override
String publicProfileLinksChecked(int count, int verified) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other:
'Geprüft: $verified von $count Links verlinken zurück auf dein Profil.',
one: 'Geprüft: $verified von 1 Link verlinkt zurück auf dein Profil.',
);
return '$_temp0';
}
@override
String get publicProfileRemoveLink => 'Link entfernen';
@ -2048,6 +2084,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get profileProblemAvatarInvalid =>
'Dieses Bild lässt sich nicht verwenden: Wähle ein PNG-, JPEG-, WebP- oder GIF-Bild.';
@override
String get profileProblemTooManyLinkChecks =>
'Die Links wurden zu oft geprüft. Versuche es in einer Stunde noch einmal.';
@override
String get publicProfilePicture => 'Bild';

View file

@ -1881,6 +1881,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get profileSheetNotFound => 'Nobody on this instance has this handle.';
@override
String get profileSheetLinkVerified => 'Links back to this profile';
@override
String get profileSheetUnreachable =>
'The profile couldn’t be loaded. Check your connection.';
@ -1931,6 +1934,38 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get publicProfileAddLink => 'Add link';
@override
String publicProfilePage(String url) {
return 'Your profile page: $url';
}
@override
String get publicProfilePageCopy => 'Copy the address';
@override
String get publicProfilePageCopied => 'Address copied.';
@override
String get publicProfileVerifyHint =>
'A link gets a checkmark once its page links back to your profile page with rel=\"me\", as Mastodon checks links.';
@override
String get publicProfileCheckLinks => 'Check links';
@override
String get publicProfileLinkVerified => 'Links back to your profile';
@override
String publicProfileLinksChecked(int count, int verified) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Checked: $verified of $count links link back to your profile.',
one: 'Checked: $verified of 1 link links back to your profile.',
);
return '$_temp0';
}
@override
String get publicProfileRemoveLink => 'Remove link';
@ -2029,6 +2064,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get profileProblemAvatarInvalid =>
'This picture can\'t be used: choose a PNG, JPEG, WebP or GIF image.';
@override
String get profileProblemTooManyLinkChecks =>
'The links were checked too often. Try again in an hour.';
@override
String get publicProfilePicture => 'Picture';

View file

@ -7,7 +7,9 @@ import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repositor
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
/// The person's public profile: reading it, saving it as it is written
/// (field by field, decided 2026-10-12), removing it. A save asked for while
/// (field by field, decided 2026-10-12), removing it, and having its links
/// checked (ADR 0011): after a save that brought a link not yet checked, and
/// when asked. A save asked for while
/// another is on its way follows it, the latest of those waiting, so none
/// is lost and none overtakes another. A handle chosen holds for every save
/// after it until one with it went through or was refused for it, so a
@ -24,6 +26,64 @@ class PublicProfileBloc(final ProfileRepository _profiles)
on<PublicProfileEventAvatarRemoved>(
(event, emit) => _change(emit, _profiles.removeAvatar),
);
on<PublicProfileEventLinksChecked>(_onLinksChecked);
}
/// The links checked by themselves already, or asked for.
final _checkedLinks = <String>{};
Future<void> _onLinksChecked(
PublicProfileEventLinksChecked event,
Emitter<PublicProfileState> emit,
) async {
if (state case PublicProfileStateReady(
:final profile?,
saving: false,
checkingLinks: false,
)) {
_checkedLinks.addAll(profile.links);
emit(PublicProfileStateReady(profile: profile, checkingLinks: true));
try {
final checked = await _profiles.checkLinks();
switch (state) {
case PublicProfileStateReady(checkingLinks: true):
emit(
PublicProfileStateReady(
profile: checked,
outcome: event.asked ? PublicProfileOutcome.linksChecked : null,
),
);
// Saved meanwhile: only the checks are new.
case PublicProfileStateReady(
profile: final current?,
:final saving,
:final problem,
:final outcome,
):
emit(
PublicProfileStateReady(
profile: current.withVerifiedLinks(checked.verifiedLinks),
saving: saving,
problem: problem,
outcome: outcome,
),
);
case _:
}
} on ProfileRefused catch (refused) {
emit(
PublicProfileStateReady(profile: profile, problem: refused.problem),
);
} on Object catch (error) {
log('Checking the links failed', name: 'Profile', error: error);
emit(
PublicProfileStateReady(
profile: profile,
outcome: event.asked ? PublicProfileOutcome.failed : null,
),
);
}
}
}
/// Changes the profile through [change], as saving it does.
@ -124,6 +184,14 @@ class PublicProfileBloc(final ProfileRepository _profiles)
outcome: PublicProfileOutcome.saved,
),
);
// A link new or changed is checked by itself.
if (saved.links.any(
(link) =>
!saved.verifiedLinks.contains(link) &&
!_checkedLinks.contains(link),
)) {
add(const PublicProfileEventLinksChecked(asked: false));
}
} on ProfileRefused catch (refused) {
if (handle == _chosenHandle &&
ProfileProblem.handleProblems.contains(refused.problem)) {
@ -179,6 +247,9 @@ enum PublicProfileOutcome() {
/// The instance couldn't be reached; nothing changed.
failed,
/// The links were checked, as asked.
linksChecked,
}
/// Where the person's public profile stands.
@ -194,16 +265,18 @@ final class const PublicProfileStateLoading() extends PublicProfileState;
final class const PublicProfileStateUnreachable() extends PublicProfileState;
/// Read: the [profile] (null while there is none), whether a change is on
/// its way, why the last one was refused ([problem]), and what the last one
/// came to ([outcome]); the last two cleared by the next state.
/// its way, whether its links are being checked, why the last change was
/// refused ([problem]), and what the last one came to ([outcome]); the last
/// two cleared by the next state.
final class const PublicProfileStateReady({
final PublicProfile? profile,
final bool saving = false,
final bool checkingLinks = false,
final ProfileProblem? problem,
final PublicProfileOutcome? outcome,
}) extends PublicProfileState {
@override
List<Object?> get props => [profile, saving, problem, outcome];
List<Object?> get props => [profile, saving, checkingLinks, problem, outcome];
}
/// What happens to the person's public profile.
@ -239,3 +312,11 @@ final class const PublicProfileEventAvatarChosen(final Uint8List picture)
/// The person removes their avatar.
final class const PublicProfileEventAvatarRemoved() extends PublicProfileEvent;
/// Check which links' pages link back to the profile page: [asked] by the
/// person, or by itself after a save that brought a link not yet checked.
final class const PublicProfileEventLinksChecked({final bool asked = true})
extends PublicProfileEvent {
@override
List<Object?> get props => [asked];
}

View file

@ -35,4 +35,9 @@ abstract interface class ProfileGateway() {
/// Shows no avatar any more; returns the profile without it.
Future<PublicProfile> removeAvatar();
/// Has the instance check which of the person's links' pages link back to
/// their profile page; returns the profile with the answer. Throws
/// [ProfileRefused] when it was asked too often.
Future<PublicProfile> checkLinks();
}

View file

@ -63,4 +63,9 @@ class ProfileRepository(
/// Shows no avatar any more.
Future<PublicProfile> removeAvatar() => _gateway.removeAvatar();
/// Has the instance check which of the person's links' pages link back to
/// their profile page with `rel="me"` (ADR 0011). Throws [ProfileRefused]
/// when it was asked too often.
Future<PublicProfile> checkLinks() => _gateway.checkLinks();
}

View file

@ -58,6 +58,10 @@ class ServerpodProfileGateway(final protocol.Client _client)
Future<PublicProfile> removeAvatar() =>
_refusing(() async => _profile(await _client.profile.removeAvatar()));
@override
Future<PublicProfile> checkLinks() =>
_refusing(() async => _profile(await _client.profile.checkLinks()));
/// [call], its refusal as a [ProfileRefused]; a reason this app doesn't
/// know reads as an invalid handle.
Future<T> _refusing<T>(Future<T> Function() call) async {
@ -77,6 +81,8 @@ class ServerpodProfileGateway(final protocol.Client _client)
displayName: profile.displayName,
bio: profile.bio,
links: profile.links,
verifiedLinks: profile.verifiedLinks ?? const [],
pageUrl: profile.pageUrl,
avatarUrl: profile.avatarUrl,
handleFixed: profile.handleFixed,
);

View file

@ -20,12 +20,37 @@ final class const PublicProfile({
/// Pages about the person, in their order.
final List<String> links = const [],
/// Those of [links] whose pages link back to [pageUrl] with `rel="me"`,
/// as the instance last checked (ADR 0011).
final List<String> verifiedLinks = const [],
/// The profile's page on the web (`https://halkyon.eu/@raisondetredev`),
/// which the person's other pages link back to; null where the instance
/// has none.
final String? pageUrl,
/// Where their avatar is, a PNG; null for none.
final String? avatarUrl,
/// Whether the handle is fixed: its person has published.
final bool handleFixed = false,
}) with Equatable {
/// This profile with [verified] as its links checked, those it still has.
PublicProfile withVerifiedLinks(List<String> verified) => PublicProfile(
handle: handle,
address: address,
displayName: displayName,
bio: bio,
links: links,
verifiedLinks: [
for (final link in verified)
if (links.contains(link)) link,
],
pageUrl: pageUrl,
avatarUrl: avatarUrl,
handleFixed: handleFixed,
);
/// The most links a profile holds, as the instance allows.
static const maxLinks = 4;
@ -48,6 +73,8 @@ final class const PublicProfile({
displayName,
bio,
links,
verifiedLinks,
pageUrl,
avatarUrl,
handleFixed,
];
@ -80,7 +107,10 @@ enum ProfileProblem() {
linkInvalid,
/// The picture can't be read, or isn't a PNG of at most 1 MB.
avatarInvalid;
avatarInvalid,
/// The links were checked too often in the last hour.
tooManyLinkChecks;
/// The problems with a handle.
static const handleProblems = {

View file

@ -107,6 +107,21 @@ class const _Profile({required final PublicProfile profile})
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Its page links back to this profile's (ADR 0011).
trailing: profile.verifiedLinks.contains(link)
? Tooltip(
message: AppLocalizations.of(
context,
).profileSheetLinkVerified,
child: Icon(
LucideIcons.badgeCheck,
color: theme.colorScheme.primary,
semanticLabel: AppLocalizations.of(
context,
).profileSheetLinkVerified,
),
)
: null,
onTap: () => unawaited(
launchUrl(
Uri.parse(link.contains('://') ? link : 'https://$link'),

Some files were not shown because too many files have changed in this diff Show more