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

View file

@ -0,0 +1,103 @@
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 {},
}) implements ProfileGateway {
/// When set, every request fails with it.
Exception? fail;
@override
Future<PublicProfile?> mine() async {
if (fail case final error?) {
throw error;
}
return profile;
}
@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 (!RegExp(r'^[A-Za-z0-9_]{1,30}$').hasMatch(handle)) {
throw ProfileRefused(ProfileProblem.handleInvalid);
}
if (taken.any((other) => other.toLowerCase() == handle.toLowerCase())) {
throw ProfileRefused(ProfileProblem.handleTaken);
}
if (current != null &&
current.handleFixed &&
current.handle.toLowerCase() != handle.toLowerCase()) {
throw ProfileRefused(ProfileProblem.published);
}
return profile = PublicProfile(
handle: handle,
address: '$handle@halkyon.eu',
displayName: displayName,
bio: bio,
links: links,
handleFixed: current?.handleFixed ?? false,
);
}
/// 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;
}
}