feat: public profiles, a handle on the account domain with name, bio, links and picture

Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.

An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.

In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.

Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-09 20:55:16 +02:00
commit 7f6147d689
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 16609 additions and 3 deletions

View file

@ -22,6 +22,8 @@ import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_source.d
as _i8vj64sp;
import 'package:halkyon_learn_client/src/protocol/instance/instance_info.dart'
as _i26u8vl4;
import 'package:halkyon_learn_client/src/protocol/profile/profile.dart'
as _ihcmr71y;
import 'package:http/http.dart' as _i85jenna;
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
as _iacc;
@ -331,7 +333,8 @@ class EndpointInstance extends _isc.EndpointRef {
String get name => 'instance';
/// This instance's offer: GitHub sign-in only with the operator's OAuth
/// app, where its source is, and the oldest app build it works with.
/// app, where its source is, the oldest app build it works with, and the
/// domain its people's handles are on.
_ida.Future<_i26u8vl4.InstanceInfo> info() =>
caller.callServerEndpoint<_i26u8vl4.InstanceInfo>(
'instance',
@ -340,6 +343,96 @@ class EndpointInstance extends _isc.EndpointRef {
);
}
/// Your public profile (ADR 0005, rule 2): the handle others find you by,
/// your display name, bio and links. Only for people with an identity: a
/// profile is for others to see.
///
/// The person always comes from the session, never from the client: a
/// profile is only ever the caller's own.
/// {@category Endpoint}
class EndpointProfile extends _isc.EndpointRef {
EndpointProfile(_isc.EndpointCaller caller) : super(caller);
@override
String get name => 'profile';
/// Your public profile; null while you have none.
_ida.Future<_ihcmr71y.Profile?> mine() =>
caller.callServerEndpoint<_ihcmr71y.Profile?>(
'profile',
'mine',
{},
);
/// Saves your public profile, making it if you have none. Throws
/// [ProfileRefusedException] when it can't be saved (see
/// [ProfileRefusal]): a handle someone else has, whatever its case, or a
/// new handle once you have published.
_ida.Future<_ihcmr71y.Profile> save(
String handle,
String displayName,
String bio,
List<String> links,
) => caller.callServerEndpoint<_ihcmr71y.Profile>(
'profile',
'save',
{
'handle': handle,
'displayName': displayName,
'bio': bio,
'links': links,
},
);
/// Shows [png] as your avatar, in place of any before: a PNG of at most
/// 1 MB, which the app draws from the picture chosen. Throws
/// [ProfileRefusedException] for anything else, and [NotFoundException]
/// while you have no profile.
_ida.Future<_ihcmr71y.Profile> setAvatar(_idt.ByteData png) =>
caller.callServerEndpoint<_ihcmr71y.Profile>(
'profile',
'setAvatar',
{'png': png},
);
/// Shows no avatar any more. Throws [NotFoundException] while you have no
/// profile.
_ida.Future<_ihcmr71y.Profile> removeAvatar() =>
caller.callServerEndpoint<_ihcmr71y.Profile>(
'profile',
'removeAvatar',
{},
);
/// Removes your public profile, freeing its handle; nothing when you have
/// none. Throws [ProfileRefusedException] once you have published: others
/// refer to it.
_ida.Future<void> remove() => caller.callServerEndpoint<void>(
'profile',
'remove',
{},
);
}
/// The public profiles of others, found by their handles. Any signed-in
/// person may read them: they are public.
/// {@category Endpoint}
class EndpointProfiles extends _isc.EndpointRef {
EndpointProfiles(_isc.EndpointCaller caller) : super(caller);
@override
String get name => 'profiles';
/// The public profile with [handle], whatever its case. Throws
/// [NotFoundException] when there is none.
_ida.Future<_ihcmr71y.Profile> byHandle(String handle) =>
caller.callServerEndpoint<_ihcmr71y.Profile>(
'profiles',
'byHandle',
{'handle': handle},
);
}
class Modules {
Modules(Client client) {
serverpod_auth_idp = _iaic.Caller(client);
@ -389,6 +482,8 @@ class Client extends _isc.ServerpodClientShared {
gitHubUpgrade = EndpointGitHubUpgrade(this);
dictionary = EndpointDictionary(this);
instance = EndpointInstance(this);
profile = EndpointProfile(this);
profiles = EndpointProfiles(this);
modules = Modules(this);
}
@ -408,6 +503,10 @@ class Client extends _isc.ServerpodClientShared {
late final EndpointInstance instance;
late final EndpointProfile profile;
late final EndpointProfiles profiles;
late final Modules modules;
@override
@ -420,6 +519,8 @@ class Client extends _isc.ServerpodClientShared {
'gitHubUpgrade': gitHubUpgrade,
'dictionary': dictionary,
'instance': instance,
'profile': profile,
'profiles': profiles,
};
@override

View file

@ -20,12 +20,14 @@ abstract class InstanceInfo
this.gitHubClientId,
required this.sourceCodeUrl,
this.minimumAppBuild,
this.accountDomain,
});
factory InstanceInfo({
String? gitHubClientId,
required String sourceCodeUrl,
int? minimumAppBuild,
String? accountDomain,
}) = _InstanceInfoImpl;
factory InstanceInfo.fromJson(Map<String, dynamic> jsonSerialization) {
@ -33,6 +35,7 @@ abstract class InstanceInfo
gitHubClientId: jsonSerialization['gitHubClientId'] as String?,
sourceCodeUrl: jsonSerialization['sourceCodeUrl'] as String,
minimumAppBuild: jsonSerialization['minimumAppBuild'] as int?,
accountDomain: jsonSerialization['accountDomain'] as String?,
);
}
@ -49,6 +52,11 @@ abstract class InstanceInfo
/// that predate it: any build.
int? minimumAppBuild;
/// The domain people's handles are on (`halkyon.eu` in
/// `raisondetredev@halkyon.eu`, ADR 0005). Null from servers that predate
/// it.
String? accountDomain;
/// Returns a shallow copy of this [InstanceInfo]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@ -56,6 +64,7 @@ abstract class InstanceInfo
String? gitHubClientId,
String? sourceCodeUrl,
int? minimumAppBuild,
String? accountDomain,
});
@override
Map<String, dynamic> toJson() {
@ -64,6 +73,7 @@ abstract class InstanceInfo
if (gitHubClientId != null) 'gitHubClientId': gitHubClientId,
'sourceCodeUrl': sourceCodeUrl,
if (minimumAppBuild != null) 'minimumAppBuild': minimumAppBuild,
if (accountDomain != null) 'accountDomain': accountDomain,
};
}
@ -74,6 +84,7 @@ abstract class InstanceInfo
if (gitHubClientId != null) 'gitHubClientId': gitHubClientId,
'sourceCodeUrl': sourceCodeUrl,
if (minimumAppBuild != null) 'minimumAppBuild': minimumAppBuild,
if (accountDomain != null) 'accountDomain': accountDomain,
};
}
@ -90,10 +101,12 @@ class _InstanceInfoImpl extends InstanceInfo {
String? gitHubClientId,
required String sourceCodeUrl,
int? minimumAppBuild,
String? accountDomain,
}) : super._(
gitHubClientId: gitHubClientId,
sourceCodeUrl: sourceCodeUrl,
minimumAppBuild: minimumAppBuild,
accountDomain: accountDomain,
);
/// Returns a shallow copy of this [InstanceInfo]
@ -104,6 +117,7 @@ class _InstanceInfoImpl extends InstanceInfo {
Object? gitHubClientId = _Undefined,
String? sourceCodeUrl,
Object? minimumAppBuild = _Undefined,
Object? accountDomain = _Undefined,
}) {
return InstanceInfo(
gitHubClientId: gitHubClientId is String?
@ -113,6 +127,9 @@ class _InstanceInfoImpl extends InstanceInfo {
minimumAppBuild: minimumAppBuild is int?
? minimumAppBuild
: this.minimumAppBuild,
accountDomain: accountDomain is String?
? accountDomain
: this.accountDomain,
);
}
}

View file

@ -0,0 +1,168 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:halkyon_learn_client/src/protocol/protocol.dart' as _itx5j3f5;
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// A person's public profile as the app shows it (ADR 0005, rule 2): never
/// whose it is, only who they say they are.
abstract class Profile
implements _isc.SerializableModel, _isc.ProtocolSerialization {
Profile._({
required this.handle,
required this.address,
required this.displayName,
required this.bio,
required this.links,
this.avatarUrl,
required this.handleFixed,
});
factory Profile({
required String handle,
required String address,
required String displayName,
required String bio,
required List<String> links,
String? avatarUrl,
required bool handleFixed,
}) = _ProfileImpl;
factory Profile.fromJson(Map<String, dynamic> jsonSerialization) {
return Profile(
handle: jsonSerialization['handle'] as String,
address: jsonSerialization['address'] as String,
displayName: jsonSerialization['displayName'] as String,
bio: jsonSerialization['bio'] as String,
links: _itx5j3f5.Protocol().deserialize<List<String>>(
jsonSerialization['links'],
),
avatarUrl: jsonSerialization['avatarUrl'] as String?,
handleFixed: _isc.BoolJsonExtension.fromJson(
jsonSerialization['handleFixed'],
),
);
}
/// The handle as its person wrote it.
String handle;
/// The handle with the account domain, as the fediverse knows it
/// (`raisondetredev@halkyon.eu`).
String address;
/// The name shown beside the handle; empty for none.
String displayName;
/// A few words about the person; empty for none.
String bio;
/// Pages about the person, in their order.
List<String> links;
/// Where their avatar is, a PNG; null for none.
String? avatarUrl;
/// Whether the handle is fixed, as its person has published.
bool handleFixed;
/// Returns a shallow copy of this [Profile]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
Profile copyWith({
String? handle,
String? address,
String? displayName,
String? bio,
List<String>? links,
String? avatarUrl,
bool? handleFixed,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'Profile',
'handle': handle,
'address': address,
'displayName': displayName,
'bio': bio,
'links': links.toJson(),
if (avatarUrl != null) 'avatarUrl': avatarUrl,
'handleFixed': handleFixed,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'Profile',
'handle': handle,
'address': address,
'displayName': displayName,
'bio': bio,
'links': links.toJson(),
if (avatarUrl != null) 'avatarUrl': avatarUrl,
'handleFixed': handleFixed,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _Undefined {}
class _ProfileImpl extends Profile {
_ProfileImpl({
required String handle,
required String address,
required String displayName,
required String bio,
required List<String> links,
String? avatarUrl,
required bool handleFixed,
}) : super._(
handle: handle,
address: address,
displayName: displayName,
bio: bio,
links: links,
avatarUrl: avatarUrl,
handleFixed: handleFixed,
);
/// Returns a shallow copy of this [Profile]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
Profile copyWith({
String? handle,
String? address,
String? displayName,
String? bio,
List<String>? links,
Object? avatarUrl = _Undefined,
bool? handleFixed,
}) {
return Profile(
handle: handle ?? this.handle,
address: address ?? this.address,
displayName: displayName ?? this.displayName,
bio: bio ?? this.bio,
links: links ?? this.links.map((e0) => e0).toList(),
avatarUrl: avatarUrl is String? ? avatarUrl : this.avatarUrl,
handleFixed: handleFixed ?? this.handleFixed,
);
}
}

View file

@ -0,0 +1,77 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// Why saving or removing a public profile was refused; nothing changed.
enum ProfileRefusal implements _isc.SerializableModel {
/// The handle isn't 1 to 30 letters (a to z), digits or underscores.
handleInvalid,
/// The handle is kept for the instance (`admin`, `support`, …).
handleReserved,
/// Someone else has the handle, whatever its case.
handleTaken,
/// The person has published: their handle can't change, nor their profile
/// go, any more.
published,
/// The display name is longer than 30 characters.
displayNameTooLong,
/// The bio is longer than 500 characters.
bioTooLong,
/// More than 4 links.
tooManyLinks,
/// A link isn't an absolute HTTP(S) URL of at most 2,000 characters.
linkInvalid,
/// The avatar isn't a PNG of at most 1 MB.
avatarInvalid;
static ProfileRefusal fromJson(String name) {
switch (name) {
case 'handleInvalid':
return ProfileRefusal.handleInvalid;
case 'handleReserved':
return ProfileRefusal.handleReserved;
case 'handleTaken':
return ProfileRefusal.handleTaken;
case 'published':
return ProfileRefusal.published;
case 'displayNameTooLong':
return ProfileRefusal.displayNameTooLong;
case 'bioTooLong':
return ProfileRefusal.bioTooLong;
case 'tooManyLinks':
return ProfileRefusal.tooManyLinks;
case 'linkInvalid':
return ProfileRefusal.linkInvalid;
case 'avatarInvalid':
return ProfileRefusal.avatarInvalid;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "ProfileRefusal"',
);
}
}
@override
String toJson() => name;
@override
String toString() => name;
}

View file

@ -0,0 +1,77 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
import '../profile/profile_refusal.dart' as _i5pl5k19;
/// Saving or removing a public profile was refused; nothing changed.
abstract class ProfileRefusedException
implements
_isc.SerializableException,
_isc.SerializableModel,
_isc.ProtocolSerialization {
ProfileRefusedException._({required this.reason});
factory ProfileRefusedException({required _i5pl5k19.ProfileRefusal reason}) =
_ProfileRefusedExceptionImpl;
factory ProfileRefusedException.fromJson(
Map<String, dynamic> jsonSerialization,
) {
return ProfileRefusedException(
reason: _i5pl5k19.ProfileRefusal.fromJson(
(jsonSerialization['reason'] as String),
),
);
}
/// Why it was refused.
_i5pl5k19.ProfileRefusal reason;
/// Returns a shallow copy of this [ProfileRefusedException]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
ProfileRefusedException copyWith({_i5pl5k19.ProfileRefusal? reason});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'ProfileRefusedException',
'reason': reason.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'ProfileRefusedException',
'reason': reason.toJson(),
};
}
@override
String toString() {
return 'ProfileRefusedException(reason: $reason)';
}
}
class _ProfileRefusedExceptionImpl extends ProfileRefusedException {
_ProfileRefusedExceptionImpl({required _i5pl5k19.ProfileRefusal reason})
: super._(reason: reason);
/// Returns a shallow copy of this [ProfileRefusedException]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
ProfileRefusedException copyWith({_i5pl5k19.ProfileRefusal? reason}) {
return ProfileRefusedException(reason: reason ?? this.reason);
}
}

View file

@ -48,6 +48,9 @@ import 'note/note.dart' as _i9njjhyv;
import 'note/note_revision.dart' as _i6j5dng4;
import 'pdf_source/pdf_reading.dart' as _i4l20c7i;
import 'pdf_source/pdf_source.dart' as _iz838ro9;
import 'profile/profile.dart' as _ia5j8fch;
import 'profile/profile_refusal.dart' as _i6j6efd1;
import 'profile/profile_refused_exception.dart' as _idvc5e7p;
import 'review/review_log.dart' as _irzjbdqz;
import 'settings/person_settings.dart' as _irnhx5e5;
import 'studyset/studyset.dart' as _ikbclmae;
@ -78,6 +81,9 @@ export 'note/note.dart';
export 'note/note_revision.dart';
export 'pdf_source/pdf_reading.dart';
export 'pdf_source/pdf_source.dart';
export 'profile/profile.dart';
export 'profile/profile_refusal.dart';
export 'profile/profile_refused_exception.dart';
export 'review/review_log.dart';
export 'settings/person_settings.dart';
export 'studyset/studyset.dart';
@ -2044,6 +2050,15 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _iz838ro9.PdfSource) {
return _iz838ro9.PdfSource.fromJson(data) as T;
}
if (t == _ia5j8fch.Profile) {
return _ia5j8fch.Profile.fromJson(data) as T;
}
if (t == _i6j6efd1.ProfileRefusal) {
return _i6j6efd1.ProfileRefusal.fromJson(data) as T;
}
if (t == _idvc5e7p.ProfileRefusedException) {
return _idvc5e7p.ProfileRefusedException.fromJson(data) as T;
}
if (t == _irzjbdqz.ReviewLog) {
return _irzjbdqz.ReviewLog.fromJson(data) as T;
}
@ -2154,6 +2169,19 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _isc.getType<_iz838ro9.PdfSource?>()) {
return (data != null ? _iz838ro9.PdfSource.fromJson(data) : null) as T;
}
if (t == _isc.getType<_ia5j8fch.Profile?>()) {
return (data != null ? _ia5j8fch.Profile.fromJson(data) : null) as T;
}
if (t == _isc.getType<_i6j6efd1.ProfileRefusal?>()) {
return (data != null ? _i6j6efd1.ProfileRefusal.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_idvc5e7p.ProfileRefusedException?>()) {
return (data != null
? _idvc5e7p.ProfileRefusedException.fromJson(data)
: null)
as T;
}
if (t == _isc.getType<_irzjbdqz.ReviewLog?>()) {
return (data != null ? _irzjbdqz.ReviewLog.fromJson(data) : null) as T;
}
@ -2214,6 +2242,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
)
as T;
}
if (t == List<String>) {
return (data as List).map((e) => deserialize<String>(e)).toList() as T;
}
try {
return _iaic.Protocol().deserialize<T>(data, t);
} on _isc.DeserializationTypeNotFoundException catch (_) {}
@ -2251,6 +2282,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
_i6j5dng4.NoteRevision => 'NoteRevision',
_i4l20c7i.PdfReading => 'PdfReading',
_iz838ro9.PdfSource => 'PdfSource',
_ia5j8fch.Profile => 'Profile',
_i6j6efd1.ProfileRefusal => 'ProfileRefusal',
_idvc5e7p.ProfileRefusedException => 'ProfileRefusedException',
_irzjbdqz.ReviewLog => 'ReviewLog',
_irnhx5e5.PersonSettings => 'PersonSettings',
_ikbclmae.Studyset => 'Studyset',
@ -2321,6 +2355,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
return 'PdfReading';
case _iz838ro9.PdfSource():
return 'PdfSource';
case _ia5j8fch.Profile():
return 'Profile';
case _i6j6efd1.ProfileRefusal():
return 'ProfileRefusal';
case _idvc5e7p.ProfileRefusedException():
return 'ProfileRefusedException';
case _irzjbdqz.ReviewLog():
return 'ReviewLog';
case _irnhx5e5.PersonSettings():
@ -2432,6 +2472,15 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (dataClassName == 'PdfSource') {
return deserialize<_iz838ro9.PdfSource>(data['data']);
}
if (dataClassName == 'Profile') {
return deserialize<_ia5j8fch.Profile>(data['data']);
}
if (dataClassName == 'ProfileRefusal') {
return deserialize<_i6j6efd1.ProfileRefusal>(data['data']);
}
if (dataClassName == 'ProfileRefusedException') {
return deserialize<_idvc5e7p.ProfileRefusedException>(data['data']);
}
if (dataClassName == 'ReviewLog') {
return deserialize<_irzjbdqz.ReviewLog>(data['data']);
}