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
25 changed files with 16609 additions and 3 deletions

View file

@ -1,5 +1,6 @@
import 'package:halkyon_learn_server/src/asset/asset_endpoint.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:halkyon_learn_server/src/profile/profile_rules.dart';
import 'package:serverpod/protocol.dart' show CloudStorageEntry;
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
@ -44,6 +45,18 @@ Future<void> deletePerson(Session session, UuidValue authUserId) =>
t.path.like('${assetPath(authUserId, '')}%'),
transaction: transaction,
);
// DELETE FROM "public_profile" WHERE "authUserId" = $1, and its avatar
// unless another profile shows the same picture.
final profiles = await PublicProfile.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(authUserId),
transaction: transaction,
);
for (final profile in profiles) {
if (profile.avatarHash case final hash?) {
await forgetAvatar(session, hash, transaction: transaction);
}
}
// DELETE FROM "file_allowance" WHERE "authUserId" = $1
await FileAllowance.db.deleteWhere(
session,

View file

@ -10,6 +10,7 @@
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:halkyon_learn_server/src/generated/future_calls.dart'
as _iip9210p;
import 'package:serverpod/serverpod.dart' as _is;
@ -27,6 +28,7 @@ import '../auth/github_sign_in_endpoint.dart' as _iasw79m5;
import '../auth/github_upgrade_endpoint.dart' as _ivnugbf2;
import '../dictionary/dictionary_endpoint.dart' as _ilgtwlto;
import '../instance/instance_endpoint.dart' as _i3g8bfc2;
import '../profile/profile_endpoint.dart' as _i6ky944g;
export 'future_calls.dart' show ServerpodFutureCallsGetter;
class Endpoints extends _is.EndpointDispatch {
@ -81,6 +83,18 @@ class Endpoints extends _is.EndpointDispatch {
'instance',
null,
),
'profile': _i6ky944g.ProfileEndpoint()
..initialize(
server,
'profile',
null,
),
'profiles': _i6ky944g.ProfilesEndpoint()
..initialize(
server,
'profiles',
null,
),
};
connectors['asset'] = _is.EndpointConnector(
name: 'asset',
@ -495,6 +509,123 @@ class Endpoints extends _is.EndpointDispatch {
),
},
);
connectors['profile'] = _is.EndpointConnector(
name: 'profile',
endpoint: endpoints['profile']!,
methodConnectors: {
'mine': _is.MethodConnector(
name: 'mine',
params: {},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async => (endpoints['profile'] as _i6ky944g.ProfileEndpoint)
.mine(session),
),
'save': _is.MethodConnector(
name: 'save',
params: {
'handle': _is.ParameterDescription(
name: 'handle',
type: _is.getType<String>(),
nullable: false,
),
'displayName': _is.ParameterDescription(
name: 'displayName',
type: _is.getType<String>(),
nullable: false,
),
'bio': _is.ParameterDescription(
name: 'bio',
type: _is.getType<String>(),
nullable: false,
),
'links': _is.ParameterDescription(
name: 'links',
type: _is.getType<List<String>>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['profile'] as _i6ky944g.ProfileEndpoint).save(
session,
params['handle'],
params['displayName'],
params['bio'],
params['links'],
),
),
'setAvatar': _is.MethodConnector(
name: 'setAvatar',
params: {
'png': _is.ParameterDescription(
name: 'png',
type: _is.getType<_idt.ByteData>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['profile'] as _i6ky944g.ProfileEndpoint).setAvatar(
session,
params['png'],
),
),
'removeAvatar': _is.MethodConnector(
name: 'removeAvatar',
params: {},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async => (endpoints['profile'] as _i6ky944g.ProfileEndpoint)
.removeAvatar(session),
),
'remove': _is.MethodConnector(
name: 'remove',
params: {},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async => (endpoints['profile'] as _i6ky944g.ProfileEndpoint)
.remove(session),
),
},
);
connectors['profiles'] = _is.EndpointConnector(
name: 'profiles',
endpoint: endpoints['profiles']!,
methodConnectors: {
'byHandle': _is.MethodConnector(
name: 'byHandle',
params: {
'handle': _is.ParameterDescription(
name: 'handle',
type: _is.getType<String>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async => (endpoints['profiles'] as _i6ky944g.ProfilesEndpoint)
.byHandle(
session,
params['handle'],
),
),
},
);
modules['serverpod_auth_idp'] = _iais.Endpoints()
..initializeEndpoints(server);
modules['serverpod_offline_sync'] = _izehhkf5.Endpoints()

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.
@_is.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_server/src/generated/protocol.dart' as _i5lsenq5;
import 'package:serverpod/serverpod.dart' as _is;
/// 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 _is.SerializableModel, _is.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: _i5lsenq5.Protocol().deserialize<List<String>>(
jsonSerialization['links'],
),
avatarUrl: jsonSerialization['avatarUrl'] as String?,
handleFixed: _is.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.
@_is.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 _is.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.
@_is.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/serverpod.dart' as _is;
/// Why saving or removing a public profile was refused; nothing changed.
enum ProfileRefusal implements _is.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/serverpod.dart' as _is;
import '../profile/profile_refusal.dart' as _i5pl5k19;
/// Saving or removing a public profile was refused; nothing changed.
abstract class ProfileRefusedException
implements
_is.SerializableException,
_is.SerializableModel,
_is.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.
@_is.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.
@_is.useResult
@override
ProfileRefusedException copyWith({_i5pl5k19.ProfileRefusal? reason}) {
return ProfileRefusedException(reason: reason ?? this.reason);
}
}

View file

@ -0,0 +1,807 @@
/* 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_server/src/generated/protocol.dart' as _i5lsenq5;
import 'package:serverpod/serverpod.dart' as _is;
/// A person's public face (ADR 0005, rule 2): the handle others find them by on
/// the instance's account domain (`raisondetredev@halkyon.eu`), a display
/// name, a bio, links and an avatar. Only people with an identity have one; deleting the
/// person deletes it. Never sent as it is: the app gets a `Profile`, without
/// the person's id.
abstract class PublicProfile
implements _is.TableRow<int?>, _is.ProtocolSerialization {
PublicProfile._({
this.id,
required this.authUserId,
required this.handle,
required this.handleKey,
required this.displayName,
required this.bio,
required this.links,
this.avatarHash,
this.firstPublishedAt,
required this.updatedAt,
});
factory PublicProfile({
int? id,
required _is.UuidValue authUserId,
required String handle,
required String handleKey,
required String displayName,
required String bio,
required List<String> links,
String? avatarHash,
DateTime? firstPublishedAt,
required DateTime updatedAt,
}) = _PublicProfileImpl;
factory PublicProfile.fromJson(Map<String, dynamic> jsonSerialization) {
return PublicProfile(
id: jsonSerialization['id'] as int?,
authUserId: _is.UuidValueJsonExtension.fromJson(
jsonSerialization['authUserId'],
),
handle: jsonSerialization['handle'] as String,
handleKey: jsonSerialization['handleKey'] as String,
displayName: jsonSerialization['displayName'] as String,
bio: jsonSerialization['bio'] as String,
links: _i5lsenq5.Protocol().deserialize<List<String>>(
jsonSerialization['links'],
),
avatarHash: jsonSerialization['avatarHash'] as String?,
firstPublishedAt: jsonSerialization['firstPublishedAt'] == null
? null
: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['firstPublishedAt'],
),
updatedAt: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['updatedAt'],
),
);
}
static final t = PublicProfileTable();
static const db = PublicProfileRepository._();
@override
int? id;
/// The person: the AuthUser's id.
_is.UuidValue authUserId;
/// The handle as its person wrote it (`Raisondetredev`).
String handle;
/// The handle in lower case: handles are one whatever their case.
String handleKey;
/// 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 (their site, their other accounts), absolute
/// HTTP(S) URLs, in their order.
List<String> links;
/// The SHA-256 of their avatar, a PNG in the public storage at
/// `avatars/<hash>.png` (named by its content, never by the person, so its
/// URL tells nothing about whose it is); null for none.
String? avatarHash;
/// When the person first published; the handle can't change from then
/// on, as followers and links refer to it. Null until then.
DateTime? firstPublishedAt;
/// When the profile last changed, in UTC.
DateTime updatedAt;
@override
_is.Table<int?> get table => t;
/// Returns a shallow copy of this [PublicProfile]
/// with some or all fields replaced by the given arguments.
@_is.useResult
PublicProfile copyWith({
int? id,
_is.UuidValue? authUserId,
String? handle,
String? handleKey,
String? displayName,
String? bio,
List<String>? links,
String? avatarHash,
DateTime? firstPublishedAt,
DateTime? updatedAt,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'PublicProfile',
if (id != null) 'id': id,
'authUserId': authUserId.toJson(),
'handle': handle,
'handleKey': handleKey,
'displayName': displayName,
'bio': bio,
'links': links.toJson(),
if (avatarHash != null) 'avatarHash': avatarHash,
if (firstPublishedAt != null)
'firstPublishedAt': firstPublishedAt?.toJson(),
'updatedAt': updatedAt.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {};
}
static PublicProfileInclude include() {
return PublicProfileInclude._();
}
static PublicProfileIncludeList includeList({
_is.WhereExpressionBuilder<PublicProfileTable>? where,
int? limit,
int? offset,
_is.OrderByBuilder<PublicProfileTable>? orderBy,
_is.OrderByListBuilder<PublicProfileTable>? orderByList,
PublicProfileInclude? include,
}) {
return PublicProfileIncludeList._(
where: where,
limit: limit,
offset: offset,
orderBy: orderBy?.call(PublicProfile.t),
orderByList: orderByList?.call(PublicProfile.t),
include: include,
);
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _Undefined {}
class _PublicProfileImpl extends PublicProfile {
_PublicProfileImpl({
int? id,
required _is.UuidValue authUserId,
required String handle,
required String handleKey,
required String displayName,
required String bio,
required List<String> links,
String? avatarHash,
DateTime? firstPublishedAt,
required DateTime updatedAt,
}) : super._(
id: id,
authUserId: authUserId,
handle: handle,
handleKey: handleKey,
displayName: displayName,
bio: bio,
links: links,
avatarHash: avatarHash,
firstPublishedAt: firstPublishedAt,
updatedAt: updatedAt,
);
/// Returns a shallow copy of this [PublicProfile]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
PublicProfile copyWith({
Object? id = _Undefined,
_is.UuidValue? authUserId,
String? handle,
String? handleKey,
String? displayName,
String? bio,
List<String>? links,
Object? avatarHash = _Undefined,
Object? firstPublishedAt = _Undefined,
DateTime? updatedAt,
}) {
return PublicProfile(
id: id is int? ? id : this.id,
authUserId: authUserId ?? this.authUserId,
handle: handle ?? this.handle,
handleKey: handleKey ?? this.handleKey,
displayName: displayName ?? this.displayName,
bio: bio ?? this.bio,
links: links ?? this.links.map((e0) => e0).toList(),
avatarHash: avatarHash is String? ? avatarHash : this.avatarHash,
firstPublishedAt: firstPublishedAt is DateTime?
? firstPublishedAt
: this.firstPublishedAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}
class PublicProfileUpdateTable extends _is.UpdateTable<PublicProfileTable> {
PublicProfileUpdateTable(super.table);
_is.ColumnValue<_is.UuidValue, _is.UuidValue> authUserId(
_is.UuidValue value,
) => _is.ColumnValue(
table.authUserId,
value,
);
_is.ColumnValue<String, String> handle(String value) => _is.ColumnValue(
table.handle,
value,
);
_is.ColumnValue<String, String> handleKey(String value) => _is.ColumnValue(
table.handleKey,
value,
);
_is.ColumnValue<String, String> displayName(String value) => _is.ColumnValue(
table.displayName,
value,
);
_is.ColumnValue<String, String> bio(String value) => _is.ColumnValue(
table.bio,
value,
);
_is.ColumnValue<List<String>, List<String>> links(List<String> value) =>
_is.ColumnValue(
table.links,
value,
);
_is.ColumnValue<String, String> avatarHash(String? value) => _is.ColumnValue(
table.avatarHash,
value,
);
_is.ColumnValue<DateTime, DateTime> firstPublishedAt(DateTime? value) =>
_is.ColumnValue(
table.firstPublishedAt,
value,
);
_is.ColumnValue<DateTime, DateTime> updatedAt(DateTime value) =>
_is.ColumnValue(
table.updatedAt,
value,
);
}
class PublicProfileTable extends _is.Table<int?> {
PublicProfileTable({super.tableRelation})
: super(tableName: 'public_profile') {
updateTable = PublicProfileUpdateTable(this);
authUserId = _is.ColumnUuid(
'authUserId',
this,
);
handle = _is.ColumnString(
'handle',
this,
);
handleKey = _is.ColumnString(
'handleKey',
this,
);
displayName = _is.ColumnString(
'displayName',
this,
);
bio = _is.ColumnString(
'bio',
this,
);
links = _is.ColumnSerializable<List<String>>(
'links',
this,
);
avatarHash = _is.ColumnString(
'avatarHash',
this,
);
firstPublishedAt = _is.ColumnDateTime(
'firstPublishedAt',
this,
);
updatedAt = _is.ColumnDateTime(
'updatedAt',
this,
);
}
late final PublicProfileUpdateTable updateTable;
/// The person: the AuthUser's id.
late final _is.ColumnUuid authUserId;
/// The handle as its person wrote it (`Raisondetredev`).
late final _is.ColumnString handle;
/// The handle in lower case: handles are one whatever their case.
late final _is.ColumnString handleKey;
/// The name shown beside the handle; empty for none.
late final _is.ColumnString displayName;
/// A few words about the person; empty for none.
late final _is.ColumnString bio;
/// Pages about the person (their site, their other accounts), absolute
/// HTTP(S) URLs, in their order.
late final _is.ColumnSerializable<List<String>> links;
/// The SHA-256 of their avatar, a PNG in the public storage at
/// `avatars/<hash>.png` (named by its content, never by the person, so its
/// URL tells nothing about whose it is); null for none.
late final _is.ColumnString avatarHash;
/// When the person first published; the handle can't change from then
/// on, as followers and links refer to it. Null until then.
late final _is.ColumnDateTime firstPublishedAt;
/// When the profile last changed, in UTC.
late final _is.ColumnDateTime updatedAt;
@override
List<_is.Column> get columns => [
id,
authUserId,
handle,
handleKey,
displayName,
bio,
links,
avatarHash,
firstPublishedAt,
updatedAt,
];
}
class PublicProfileInclude extends _is.IncludeObject {
PublicProfileInclude._();
@override
Map<String, _is.Include?> get includes => {};
@override
_is.Table<int?> get table => PublicProfile.t;
}
class PublicProfileIncludeList extends _is.IncludeList {
PublicProfileIncludeList._({
_is.WhereExpressionBuilder<PublicProfileTable>? where,
super.limit,
super.offset,
super.orderBy,
super.orderByList,
super.include,
}) {
super.where = where?.call(PublicProfile.t);
}
@override
Map<String, _is.Include?> get includes => include?.includes ?? {};
@override
_is.Table<int?> get table => PublicProfile.t;
}
class PublicProfileRepository {
const PublicProfileRepository._();
/// Returns a list of [PublicProfile]s matching the given query parameters.
///
/// Use [where] to specify which items to include in the return value.
/// If none is specified, all items will be returned.
///
/// To specify the order of the items use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// The maximum number of items can be set by [limit]. If no limit is set,
/// all items matching the query will be returned.
///
/// [offset] defines how many items to skip, after which [limit] (or all)
/// items are read from the database.
///
/// ```dart
/// var persons = await Persons.db.find(
/// session,
/// where: (t) => t.lastName.equals('Jones'),
/// orderBy: (t) => t.firstName,
/// limit: 100,
/// );
/// ```
Future<List<PublicProfile>> find(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<PublicProfileTable>? where,
int? limit,
int? offset,
_is.OrderByBuilder<PublicProfileTable>? orderBy,
_is.OrderByListBuilder<PublicProfileTable>? orderByList,
_is.Transaction? transaction,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.find<PublicProfile>(
where: where?.call(PublicProfile.t),
orderBy: orderBy?.call(PublicProfile.t),
orderByList: orderByList?.call(PublicProfile.t),
limit: limit,
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Returns the first matching [PublicProfile] matching the given query parameters.
///
/// Use [where] to specify which items to include in the return value.
/// If none is specified, all items will be returned.
///
/// To specify the order use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// [offset] defines how many items to skip, after which the next one will be picked.
///
/// ```dart
/// var youngestPerson = await Persons.db.findFirstRow(
/// session,
/// where: (t) => t.lastName.equals('Jones'),
/// orderBy: (t) => t.age,
/// );
/// ```
Future<PublicProfile?> findFirstRow(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<PublicProfileTable>? where,
int? offset,
_is.OrderByBuilder<PublicProfileTable>? orderBy,
_is.OrderByListBuilder<PublicProfileTable>? orderByList,
_is.Transaction? transaction,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.findFirstRow<PublicProfile>(
where: where?.call(PublicProfile.t),
orderBy: orderBy?.call(PublicProfile.t),
orderByList: orderByList?.call(PublicProfile.t),
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Finds a single [PublicProfile] by its [id] or null if no such row exists.
Future<PublicProfile?> findById(
_is.DatabaseSession session,
int id, {
_is.Transaction? transaction,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.findById<PublicProfile>(
id,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Inserts all [PublicProfile]s in the list and returns the inserted rows.
///
/// The returned [PublicProfile]s will have their `id` fields set.
///
/// This is an atomic operation, meaning that if one of the rows fails to
/// insert, none of the rows will be inserted.
///
/// If [ignoreConflicts] is set to `true`, rows that conflict with existing
/// rows are silently skipped, and only the successfully inserted rows are
/// returned.
///
/// If [noReturn] is set to `true`, the inserted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<PublicProfile>> insert(
_is.DatabaseSession session,
List<PublicProfile> rows, {
_is.Transaction? transaction,
bool ignoreConflicts = false,
bool noReturn = false,
}) async {
return session.db.insert<PublicProfile>(
rows,
transaction: transaction,
ignoreConflicts: ignoreConflicts,
noReturn: noReturn,
);
}
/// Inserts a single [PublicProfile] and returns the inserted row.
///
/// The returned [PublicProfile] will have its `id` field set.
Future<PublicProfile> insertRow(
_is.DatabaseSession session,
PublicProfile row, {
_is.Transaction? transaction,
}) async {
return session.db.insertRow<PublicProfile>(
row,
transaction: transaction,
);
}
/// Upserts all [PublicProfile]s in the list and returns the resulting rows.
///
/// If a row conflicts on the given [conflictColumns], the existing row is
/// updated with the new values. Otherwise, a new row is inserted.
///
/// If [updateColumns] is provided, only those columns will be updated on
/// conflict. If null, all non-conflict, non-id columns are updated.
///
/// If [updateWhere] is provided, the update only applies to rows matching the
/// given expression. Conflicting rows that don't match are skipped and not
/// returned, so the resulting list may be shorter than [rows].
///
/// The returned [PublicProfile]s will have their `id` fields set.
///
/// This is an atomic operation, meaning that if one of the rows fails,
/// none of the rows will be affected.
///
/// If [noReturn] is set to `true`, the resulting rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<PublicProfile>> upsert(
_is.DatabaseSession session,
List<PublicProfile> rows, {
required _is.ColumnSelections<PublicProfileTable> conflictColumns,
_is.ColumnSelections<PublicProfileTable>? updateColumns,
_is.WhereExpressionBuilder<PublicProfileTable>? updateWhere,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.upsert<PublicProfile>(
rows,
conflictColumns: conflictColumns(PublicProfile.t),
updateColumns: updateColumns?.call(PublicProfile.t),
updateWhere: updateWhere?.call(PublicProfile.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Upserts a single [PublicProfile] and returns the resulting row.
///
/// If the row conflicts on the given [conflictColumns], the existing row is
/// updated. Otherwise, a new row is inserted.
///
/// If [updateColumns] is provided, only those columns will be updated on
/// conflict. If null, all non-conflict, non-id columns are updated.
///
/// If [updateWhere] is provided, the update only applies when the existing
/// row matches the expression. Returns `null` if no row was affected — for
/// example when [updateWhere] does not match the conflicting row.
///
/// The returned [PublicProfile] will have its `id` field set.
Future<PublicProfile?> upsertRow(
_is.DatabaseSession session,
PublicProfile row, {
required _is.ColumnSelections<PublicProfileTable> conflictColumns,
_is.ColumnSelections<PublicProfileTable>? updateColumns,
_is.WhereExpressionBuilder<PublicProfileTable>? updateWhere,
_is.Transaction? transaction,
}) async {
return session.db.upsertRow<PublicProfile>(
row,
conflictColumns: conflictColumns(PublicProfile.t),
updateColumns: updateColumns?.call(PublicProfile.t),
updateWhere: updateWhere?.call(PublicProfile.t),
transaction: transaction,
);
}
/// Updates all [PublicProfile]s in the list and returns the updated rows. If
/// [columns] is provided, only those columns will be updated. Defaults to
/// all columns.
/// This is an atomic operation, meaning that if one of the rows fails to
/// update, none of the rows will be updated.
///
/// If [noReturn] is set to `true`, the updated rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<PublicProfile>> update(
_is.DatabaseSession session,
List<PublicProfile> rows, {
_is.ColumnSelections<PublicProfileTable>? columns,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.update<PublicProfile>(
rows,
columns: columns?.call(PublicProfile.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Updates a single [PublicProfile]. The row needs to have its id set.
/// Optionally, a list of [columns] can be provided to only update those
/// columns. Defaults to all columns.
Future<PublicProfile> updateRow(
_is.DatabaseSession session,
PublicProfile row, {
_is.ColumnSelections<PublicProfileTable>? columns,
_is.Transaction? transaction,
}) async {
return session.db.updateRow<PublicProfile>(
row,
columns: columns?.call(PublicProfile.t),
transaction: transaction,
);
}
/// Updates a single [PublicProfile] by its [id] with the specified [columnValues].
/// Returns the updated row or null if no row with the given id exists.
Future<PublicProfile?> updateById(
_is.DatabaseSession session,
int id, {
required _is.ColumnValueListBuilder<PublicProfileUpdateTable> columnValues,
_is.Transaction? transaction,
}) async {
return session.db.updateById<PublicProfile>(
id,
columnValues: columnValues(PublicProfile.t.updateTable),
transaction: transaction,
);
}
/// Updates all [PublicProfile]s matching the [where] expression with the specified [columnValues].
/// Returns the list of updated rows.
///
/// If [noReturn] is set to `true`, the updated rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<PublicProfile>> updateWhere(
_is.DatabaseSession session, {
required _is.ColumnValueListBuilder<PublicProfileUpdateTable> columnValues,
required _is.WhereExpressionBuilder<PublicProfileTable> where,
int? limit,
int? offset,
_is.OrderByBuilder<PublicProfileTable>? orderBy,
_is.OrderByListBuilder<PublicProfileTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.updateWhere<PublicProfile>(
columnValues: columnValues(PublicProfile.t.updateTable),
where: where(PublicProfile.t),
limit: limit,
offset: offset,
orderBy: orderBy?.call(PublicProfile.t),
orderByList: orderByList?.call(PublicProfile.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes all [PublicProfile]s in the list and returns the deleted rows.
///
/// To specify the order of the returned rows use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// This is an atomic operation, meaning that if one of the rows fail to
/// be deleted, none of the rows will be deleted.
///
/// If [noReturn] is set to `true`, the deleted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<PublicProfile>> delete(
_is.DatabaseSession session,
List<PublicProfile> rows, {
_is.OrderByBuilder<PublicProfileTable>? orderBy,
_is.OrderByListBuilder<PublicProfileTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.delete<PublicProfile>(
rows,
orderBy: orderBy?.call(PublicProfile.t),
orderByList: orderByList?.call(PublicProfile.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes a single [PublicProfile].
Future<PublicProfile> deleteRow(
_is.DatabaseSession session,
PublicProfile row, {
_is.Transaction? transaction,
}) async {
return session.db.deleteRow<PublicProfile>(
row,
transaction: transaction,
);
}
/// Deletes all rows matching the [where] expression.
///
/// To specify the order of the returned rows use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// If [noReturn] is set to `true`, the deleted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<PublicProfile>> deleteWhere(
_is.DatabaseSession session, {
required _is.WhereExpressionBuilder<PublicProfileTable> where,
_is.OrderByBuilder<PublicProfileTable>? orderBy,
_is.OrderByListBuilder<PublicProfileTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.deleteWhere<PublicProfile>(
where: where(PublicProfile.t),
orderBy: orderBy?.call(PublicProfile.t),
orderByList: orderByList?.call(PublicProfile.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Counts the number of rows matching the [where] expression. If omitted,
/// will return the count of all rows in the table.
Future<int> count(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<PublicProfileTable>? where,
int? limit,
_is.Transaction? transaction,
}) async {
return session.db.count<PublicProfile>(
where: where?.call(PublicProfile.t),
limit: limit,
transaction: transaction,
);
}
/// Acquires row-level locks on [PublicProfile] rows matching the [where] expression.
Future<void> lockRows(
_is.DatabaseSession session, {
required _is.WhereExpressionBuilder<PublicProfileTable> where,
required _is.LockMode lockMode,
required _is.Transaction transaction,
_is.LockBehavior lockBehavior = _is.LockBehavior.wait,
}) async {
return session.db.lockRows<PublicProfile>(
where: where(PublicProfile.t),
lockMode: lockMode,
lockBehavior: lockBehavior,
transaction: transaction,
);
}
}

View file

@ -48,6 +48,10 @@ 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 'profile/public_profile.dart' as _ioppe2ep;
import 'review/review_log.dart' as _irzjbdqz;
import 'settings/person_settings.dart' as _irnhx5e5;
import 'studyset/studyset.dart' as _ikbclmae;
@ -78,6 +82,10 @@ 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 'profile/public_profile.dart';
export 'review/review_log.dart';
export 'settings/person_settings.dart';
export 'studyset/studyset.dart';
@ -1919,6 +1927,105 @@ class Protocol extends _is.DatabaseSerializationManager {
],
managed: true,
),
_isp.TableDefinition(
name: 'public_profile',
dartName: 'PublicProfile',
schema: 'public',
module: 'halkyon_learn',
columns: [
_isp.ColumnDefinition(
name: 'id',
columnType: _isp.ColumnType.bigint,
isNullable: false,
dartType: 'int?',
columnDefault: 'serial',
),
_isp.ColumnDefinition(
name: 'authUserId',
columnType: _isp.ColumnType.uuid,
isNullable: false,
dartType: 'UuidValue',
),
_isp.ColumnDefinition(
name: 'handle',
columnType: _isp.ColumnType.text,
isNullable: false,
dartType: 'String',
),
_isp.ColumnDefinition(
name: 'handleKey',
columnType: _isp.ColumnType.text,
isNullable: false,
dartType: 'String',
),
_isp.ColumnDefinition(
name: 'displayName',
columnType: _isp.ColumnType.text,
isNullable: false,
dartType: 'String',
),
_isp.ColumnDefinition(
name: 'bio',
columnType: _isp.ColumnType.text,
isNullable: false,
dartType: 'String',
),
_isp.ColumnDefinition(
name: 'links',
columnType: _isp.ColumnType.json,
isNullable: false,
dartType: 'List<String>',
),
_isp.ColumnDefinition(
name: 'avatarHash',
columnType: _isp.ColumnType.text,
isNullable: true,
dartType: 'String?',
),
_isp.ColumnDefinition(
name: 'firstPublishedAt',
columnType: _isp.ColumnType.timestampWithoutTimeZone,
isNullable: true,
dartType: 'DateTime?',
),
_isp.ColumnDefinition(
name: 'updatedAt',
columnType: _isp.ColumnType.timestampWithoutTimeZone,
isNullable: false,
dartType: 'DateTime',
),
],
foreignKeys: [],
indexes: [
_isp.IndexDefinition(
indexName: 'public_profile_person_idx',
tableSpace: null,
elements: [
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'authUserId',
),
],
type: 'btree',
isUnique: true,
isPrimary: false,
),
_isp.IndexDefinition(
indexName: 'public_profile_handle_idx',
tableSpace: null,
elements: [
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'handleKey',
),
],
type: 'btree',
isUnique: true,
isPrimary: false,
),
],
managed: true,
),
_isp.TableDefinition(
name: 'review_log',
dartName: 'ReviewLog',
@ -2322,6 +2429,18 @@ class Protocol extends _is.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 == _ioppe2ep.PublicProfile) {
return _ioppe2ep.PublicProfile.fromJson(data) as T;
}
if (t == _irzjbdqz.ReviewLog) {
return _irzjbdqz.ReviewLog.fromJson(data) as T;
}
@ -2432,6 +2551,23 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _is.getType<_iz838ro9.PdfSource?>()) {
return (data != null ? _iz838ro9.PdfSource.fromJson(data) : null) as T;
}
if (t == _is.getType<_ia5j8fch.Profile?>()) {
return (data != null ? _ia5j8fch.Profile.fromJson(data) : null) as T;
}
if (t == _is.getType<_i6j6efd1.ProfileRefusal?>()) {
return (data != null ? _i6j6efd1.ProfileRefusal.fromJson(data) : null)
as T;
}
if (t == _is.getType<_idvc5e7p.ProfileRefusedException?>()) {
return (data != null
? _idvc5e7p.ProfileRefusedException.fromJson(data)
: null)
as T;
}
if (t == _is.getType<_ioppe2ep.PublicProfile?>()) {
return (data != null ? _ioppe2ep.PublicProfile.fromJson(data) : null)
as T;
}
if (t == _is.getType<_irzjbdqz.ReviewLog?>()) {
return (data != null ? _irzjbdqz.ReviewLog.fromJson(data) : null) as T;
}
@ -2492,6 +2628,9 @@ class Protocol extends _is.DatabaseSerializationManager {
)
as T;
}
if (t == List<String>) {
return (data as List).map((e) => deserialize<String>(e)).toList() as T;
}
try {
return _iais.Protocol().deserialize<T>(data, t);
} on _is.DeserializationTypeNotFoundException catch (_) {}
@ -2532,6 +2671,10 @@ class Protocol extends _is.DatabaseSerializationManager {
_i6j5dng4.NoteRevision => 'NoteRevision',
_i4l20c7i.PdfReading => 'PdfReading',
_iz838ro9.PdfSource => 'PdfSource',
_ia5j8fch.Profile => 'Profile',
_i6j6efd1.ProfileRefusal => 'ProfileRefusal',
_idvc5e7p.ProfileRefusedException => 'ProfileRefusedException',
_ioppe2ep.PublicProfile => 'PublicProfile',
_irzjbdqz.ReviewLog => 'ReviewLog',
_irnhx5e5.PersonSettings => 'PersonSettings',
_ikbclmae.Studyset => 'Studyset',
@ -2602,6 +2745,14 @@ class Protocol extends _is.DatabaseSerializationManager {
return 'PdfReading';
case _iz838ro9.PdfSource():
return 'PdfSource';
case _ia5j8fch.Profile():
return 'Profile';
case _i6j6efd1.ProfileRefusal():
return 'ProfileRefusal';
case _idvc5e7p.ProfileRefusedException():
return 'ProfileRefusedException';
case _ioppe2ep.PublicProfile():
return 'PublicProfile';
case _irzjbdqz.ReviewLog():
return 'ReviewLog';
case _irnhx5e5.PersonSettings():
@ -2717,6 +2868,18 @@ class Protocol extends _is.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 == 'PublicProfile') {
return deserialize<_ioppe2ep.PublicProfile>(data['data']);
}
if (dataClassName == 'ReviewLog') {
return deserialize<_irzjbdqz.ReviewLog>(data['data']);
}
@ -2818,6 +2981,8 @@ class Protocol extends _is.DatabaseSerializationManager {
return _i4l20c7i.PdfReading.t;
case _iz838ro9.PdfSource:
return _iz838ro9.PdfSource.t;
case _ioppe2ep.PublicProfile:
return _ioppe2ep.PublicProfile.t;
case _irzjbdqz.ReviewLog:
return _irzjbdqz.ReviewLog.t;
case _irnhx5e5.PersonSettings:

View file

@ -22,3 +22,11 @@ dictionary:
- changed:
instance:
- info:
profile:
- mine:
- save:
- setAvatar:
- removeAvatar:
- remove:
profiles:
- byHandle:

View file

@ -2,17 +2,20 @@ import 'package:halkyon_learn_server/src/auth/github.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:halkyon_learn_server/src/instance/app_compatibility.dart';
import 'package:halkyon_learn_server/src/instance/source_code.dart';
import 'package:halkyon_learn_server/src/profile/profile_rules.dart';
import 'package:serverpod/serverpod.dart';
/// What this instance offers. Public: the app asks before anyone is signed in.
class InstanceEndpoint extends Endpoint {
/// 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.
Future<InstanceInfo> info(Session session) async => InstanceInfo(
gitHubClientId: gitHubSignInAvailable(session.serverpod)
? session.serverpod.getPassword('githubClientId')
: null,
sourceCodeUrl: sourceCodeUrl(),
minimumAppBuild: minimumAppBuild,
accountDomain: accountDomain(session.serverpod),
);
}

View file

@ -12,3 +12,7 @@ fields:
### server still works with; older apps ask to be updated. Null from servers
### that predate it: any build.
minimumAppBuild: int?
### The domain people's handles are on (`halkyon.eu` in
### `raisondetredev@halkyon.eu`, ADR 0005). Null from servers that predate
### it.
accountDomain: String?

View file

@ -0,0 +1,19 @@
### A person's public profile as the app shows it (ADR 0005, rule 2): never
### whose it is, only who they say they are.
class: Profile
fields:
### The handle as its person wrote it.
handle: String
### The handle with the account domain, as the fediverse knows it
### (`raisondetredev@halkyon.eu`).
address: String
### The name shown beside the handle; empty for none.
displayName: String
### A few words about the person; empty for none.
bio: String
### Pages about the person, in their order.
links: List<String>
### Where their avatar is, a PNG; null for none.
avatarUrl: String?
### Whether the handle is fixed, as its person has published.
handleFixed: bool

View file

@ -0,0 +1,240 @@
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:crypto/crypto.dart';
import 'package:halkyon_learn_server/src/auth/scopes.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:halkyon_learn_server/src/profile/profile_rules.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// 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.
class ProfileEndpoint extends Endpoint {
@override
Set<Scope> get requiredScopes => {identified};
/// Your public profile; null while you have none.
Future<Profile?> mine(Session session) async {
// SELECT … FROM "public_profile" WHERE "authUserId" = $1
final row = await PublicProfile.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(session.authenticated!.authUserId),
);
return row == null ? null : await profileOf(session, row);
}
/// 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.
Future<Profile> save(
Session session,
String handle,
String displayName,
String bio,
List<String> links,
) async {
final person = session.authenticated!.authUserId;
final trimmed = (
displayName: displayName.trim(),
bio: bio.trim(),
links: [
for (final link in links)
if (link.trim().isNotEmpty) link.trim(),
],
);
if (profileRefusal(
handle: handle,
displayName: trimmed.displayName,
bio: trimmed.bio,
links: trimmed.links,
)
case final reason?) {
throw ProfileRefusedException(reason: reason);
}
final key = handle.toLowerCase();
try {
final saved = await session.db.transaction((transaction) async {
// SELECT … FROM "public_profile" WHERE "authUserId" = $1
final mine = await PublicProfile.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(person),
transaction: transaction,
);
if (mine != null &&
mine.firstPublishedAt != null &&
mine.handleKey != key) {
throw ProfileRefusedException(reason: ProfileRefusal.published);
}
// SELECT … FROM "public_profile"
// WHERE "handleKey" = $1 AND "authUserId" <> $2
final theirs = await PublicProfile.db.findFirstRow(
session,
where: (t) =>
t.handleKey.equals(key) & t.authUserId.notEquals(person),
transaction: transaction,
);
if (theirs != null) {
throw ProfileRefusedException(reason: ProfileRefusal.handleTaken);
}
final now = clock.now().toUtc();
if (mine == null) {
return await PublicProfile.db.insertRow(
session,
PublicProfile(
authUserId: person,
handle: handle,
handleKey: key,
displayName: trimmed.displayName,
bio: trimmed.bio,
links: trimmed.links,
updatedAt: now,
),
transaction: transaction,
);
}
// UPDATE "public_profile" SET … WHERE "id" = $1 (the caller's row)
return await PublicProfile.db.updateRow(
session,
mine.copyWith(
handle: handle,
handleKey: key,
displayName: trimmed.displayName,
bio: trimmed.bio,
links: trimmed.links,
updatedAt: now,
),
transaction: transaction,
);
});
return await profileOf(session, saved);
} on DatabaseQueryException catch (error) {
// Someone took the handle between the check and the write: the unique
// index refused it (unique_violation).
if (error.code == '23505') {
throw ProfileRefusedException(reason: ProfileRefusal.handleTaken);
}
rethrow;
}
}
/// 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.
Future<Profile> setAvatar(Session session, ByteData png) async {
if (!isAvatar(png)) {
throw ProfileRefusedException(reason: ProfileRefusal.avatarInvalid);
}
final mine = await _mine(session);
final hash = sha256
.convert(png.buffer.asUint8List(png.offsetInBytes, png.lengthInBytes))
.toString();
if (!await session.storage.fileExists(
storageId: avatarStorage,
path: avatarPath(hash),
)) {
await session.storage.storeFile(
storageId: avatarStorage,
path: avatarPath(hash),
byteData: png,
);
}
// UPDATE "public_profile" SET "avatarHash" = $1 WHERE "id" = $2 (the
// caller's row)
final saved = await PublicProfile.db.updateRow(
session,
mine.copyWith(avatarHash: hash, updatedAt: clock.now().toUtc()),
);
if (mine.avatarHash case final before? when before != hash) {
await forgetAvatar(session, before);
}
return await profileOf(session, saved);
}
/// Shows no avatar any more. Throws [NotFoundException] while you have no
/// profile.
Future<Profile> removeAvatar(Session session) async {
final mine = await _mine(session);
final saved = await PublicProfile.db.updateRow(
session,
mine.copyWith(
// Not the default: the generated copyWith tells an explicit null
// (clear it) from no argument (keep it), which the lint can't see.
// ignore: avoid_redundant_argument_values
avatarHash: null,
updatedAt: clock.now().toUtc(),
),
);
if (mine.avatarHash case final before?) {
await forgetAvatar(session, before);
}
return await profileOf(session, saved);
}
/// The caller's profile row. Throws [NotFoundException] when they have
/// none.
Future<PublicProfile> _mine(Session session) async {
// SELECT … FROM "public_profile" WHERE "authUserId" = $1
final mine = await PublicProfile.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(session.authenticated!.authUserId),
);
if (mine == null) {
throw NotFoundException(what: 'profile');
}
return mine;
}
/// Removes your public profile, freeing its handle; nothing when you have
/// none. Throws [ProfileRefusedException] once you have published: others
/// refer to it.
Future<void> remove(Session session) async {
final person = session.authenticated!.authUserId;
// SELECT … FROM "public_profile" WHERE "authUserId" = $1
final mine = await PublicProfile.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(person),
);
if (mine == null) {
return;
}
if (mine.firstPublishedAt != null) {
throw ProfileRefusedException(reason: ProfileRefusal.published);
}
// DELETE FROM "public_profile" WHERE "authUserId" = $1
await PublicProfile.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(person),
);
if (mine.avatarHash case final hash?) {
await forgetAvatar(session, hash);
}
}
}
/// The public profiles of others, found by their handles. Any signed-in
/// person may read them: they are public.
class ProfilesEndpoint extends Endpoint {
@override
bool get requireLogin => true;
/// The public profile with [handle], whatever its case. Throws
/// [NotFoundException] when there is none.
Future<Profile> byHandle(Session session, String handle) async {
// SELECT … FROM "public_profile" WHERE "handleKey" = $1
final row = await PublicProfile.db.findFirstRow(
session,
where: (t) => t.handleKey.equals(handle.toLowerCase()),
);
if (row == null) {
throw NotFoundException(what: 'profile');
}
return await profileOf(session, row);
}
}

View file

@ -0,0 +1,23 @@
### Why saving or removing a public profile was refused; nothing changed.
enum: ProfileRefusal
serialized: byName
values:
### 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

View file

@ -0,0 +1,5 @@
### Saving or removing a public profile was refused; nothing changed.
exception: ProfileRefusedException
fields:
### Why it was refused.
reason: ProfileRefusal

View file

@ -0,0 +1,154 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:serverpod/protocol.dart' show CloudStorageEntry;
import 'package:serverpod/serverpod.dart';
/// Handles kept for the instance itself, whatever their case: names people
/// would take for the instance's own, and paths the instance may need.
const reservedHandles = {
'abuse',
'admin',
'administrator',
'api',
'halkyon',
'help',
'info',
'instance',
'moderator',
'postmaster',
'root',
'security',
'settings',
'support',
'system',
'webmaster',
};
/// The longest display name.
const maxDisplayNameLength = 30;
/// The longest bio.
const maxBioLength = 500;
/// The most links a profile holds.
const maxLinks = 4;
/// The longest link.
const maxLinkLength = 2000;
/// The largest avatar, in bytes.
const maxAvatarBytes = 1024 * 1024;
/// Where avatars are kept: Serverpod's public storage, served to anyone.
const avatarStorage = 'public';
/// The path of the avatar whose SHA-256 is [hash], in [avatarStorage].
String avatarPath(String hash) => 'avatars/$hash.png';
/// Whether [bytes] are a PNG of at most [maxAvatarBytes]: by its signature;
/// the app sends one it drew itself, without the picture's metadata.
bool isAvatar(ByteData bytes) {
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
if (bytes.lengthInBytes > maxAvatarBytes ||
bytes.lengthInBytes <= signature.length) {
return false;
}
for (final (i, byte) in signature.indexed) {
if (bytes.getUint8(i) != byte) {
return false;
}
}
return true;
}
final _handle = RegExp(r'^[A-Za-z0-9_]{1,30}$');
/// Why a profile of [handle], [displayName], [bio] and [links] can't be
/// saved, judged by itself (not whether its handle is free); null when it
/// can. Handles as Mastodon's: 1 to 30 letters a to z, digits or
/// underscores, so they read the same everywhere in the fediverse.
ProfileRefusal? profileRefusal({
required String handle,
required String displayName,
required String bio,
required List<String> links,
}) {
if (!_handle.hasMatch(handle)) {
return ProfileRefusal.handleInvalid;
}
if (reservedHandles.contains(handle.toLowerCase())) {
return ProfileRefusal.handleReserved;
}
if (displayName.runes.length > maxDisplayNameLength) {
return ProfileRefusal.displayNameTooLong;
}
if (bio.runes.length > maxBioLength) {
return ProfileRefusal.bioTooLong;
}
if (links.length > maxLinks) {
return ProfileRefusal.tooManyLinks;
}
for (final link in links) {
final uri = Uri.tryParse(link);
if (link.length > maxLinkLength ||
uri == null ||
!(uri.isScheme('https') || uri.isScheme('http')) ||
uri.host.isEmpty) {
return ProfileRefusal.linkInvalid;
}
}
return null;
}
/// The domain people's handles are on (ADR 0005, rule 2): the operator's
/// `ACCOUNT_DOMAIN` (`halkyon.eu`), else the server's own public host. It
/// can never change once anyone follows a handle on it.
String accountDomain(Serverpod pod) =>
switch (Platform.environment['ACCOUNT_DOMAIN']?.trim()) {
final domain? when domain.isNotEmpty => domain,
_ => pod.config.apiServer.publicHost,
};
/// [row] as the app sees it: without whose it is.
Future<Profile> profileOf(Session session, PublicProfile row) async => Profile(
handle: row.handle,
address: '${row.handle}@${accountDomain(session.serverpod)}',
displayName: row.displayName,
bio: row.bio,
links: row.links,
avatarUrl: switch (row.avatarHash) {
final hash? => (await session.storage.publicDownloadUrl(
storageId: avatarStorage,
path: avatarPath(hash),
)).toString(),
null => null,
},
handleFixed: row.firstPublishedAt != null,
);
/// Deletes the avatar whose SHA-256 is [hash] unless a profile still shows
/// it: two people may have chosen the same picture.
Future<void> forgetAvatar(
Session session,
String hash, {
Transaction? transaction,
}) async {
// SELECT count(*) FROM "public_profile" WHERE "avatarHash" = $1
final shown = await PublicProfile.db.count(
session,
where: (t) => t.avatarHash.equals(hash),
transaction: transaction,
);
if (shown == 0) {
// DELETE FROM "serverpod_cloud_storage"
// WHERE "storageId" = 'public' AND "path" = 'avatars/<hash>.png'
await CloudStorageEntry.db.deleteWhere(
session,
where: (t) =>
t.storageId.equals(avatarStorage) & t.path.equals(avatarPath(hash)),
transaction: transaction,
);
}
}

View file

@ -0,0 +1,38 @@
### A person's public face (ADR 0005, rule 2): the handle others find them by on
### the instance's account domain (`raisondetredev@halkyon.eu`), a display
### name, a bio, links and an avatar. Only people with an identity have one; deleting the
### person deletes it. Never sent as it is: the app gets a `Profile`, without
### the person's id.
class: PublicProfile
serverOnly: true
table: public_profile
fields:
### The person: the AuthUser's id.
authUserId: UuidValue
### The handle as its person wrote it (`Raisondetredev`).
handle: String
### The handle in lower case: handles are one whatever their case.
handleKey: String
### The name shown beside the handle; empty for none.
displayName: String
### A few words about the person; empty for none.
bio: String
### Pages about the person (their site, their other accounts), absolute
### HTTP(S) URLs, in their order.
links: List<String>
### The SHA-256 of their avatar, a PNG in the public storage at
### `avatars/<hash>.png` (named by its content, never by the person, so its
### URL tells nothing about whose it is); null for none.
avatarHash: String?
### When the person first published; the handle can't change from then
### on, as followers and links refer to it. Null until then.
firstPublishedAt: DateTime?
### When the profile last changed, in UTC.
updatedAt: DateTime
indexes:
public_profile_person_idx:
fields: authUserId
unique: true
public_profile_handle_idx:
fields: handleKey
unique: true

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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