refactor(profile): handles and profiles move into halkyon_base
People's public profiles are the instance's register's (ADR 0016), so the module now holds them: the public_profile table (unchanged, so Learn's migration records versions only; devices get the table empty, as they hold every module's tables), the profile and profiles endpoints under `base`, the handle rules, the link checks and the page at /@handle, whose foot names the app as the host's configuration says (profilePageNote). Deleting a person deletes their profile in the module's core. Hosts read profiles through the module (profileOf, profilesOf, personWithHandle, fixHandle; saveProfile for their tests), behind an AccountRegister that is this server's own database for now. Learn's feedback, discussions and publishing ask it; publishing fixes the handle just before it writes the release, outside its transaction. Explore still joins the table in its search, so Learn holds the register wherever it runs. Learn's app calls the module's endpoints; a profile not found is ProfileNotFoundException. Todo's database gets the table too (it was never deployed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
2033e5606f
commit
4107cc3087
39 changed files with 38851 additions and 1197 deletions
|
|
@ -8,7 +8,6 @@ import 'package:halkyon_learn_server/src/dictionary/device_dictionaries.dart';
|
|||
import 'package:halkyon_learn_server/src/dictionary/dictionary_loader.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/serverpod.dart';
|
||||
import 'package:halkyon_learn_server/src/instance/jellyfin_suggestion.dart';
|
||||
import 'package:halkyon_learn_server/src/profile/profile_page.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
||||
/// The starting point of the Serverpod server.
|
||||
|
|
@ -33,7 +32,8 @@ Future<void> run(List<String> args) async {
|
|||
|
||||
// The assistant connector, on the web server (ADR 0006).
|
||||
addConnectorRoutes(pod);
|
||||
// Profile pages, `/@handle`, which profile links link back to (ADR 0011).
|
||||
// Profile pages, `/@handle`, which profile links link back to (ADR 0011),
|
||||
// halkyon_base's since profiles are the instance's register's (ADR 0016).
|
||||
addProfilePageRoute(pod);
|
||||
// What devices download to look Japanese up by themselves (ADR 0012).
|
||||
addDeviceDictionaryRoute(pod);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import 'package:halkyon_base_server/base.dart';
|
|||
import 'package:halkyon_learn_server/src/asset/asset_endpoint.dart';
|
||||
import 'package:halkyon_learn_server/src/discussion/discussion_delete.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_learn_server/src/profile/profile_rules.dart';
|
||||
import 'package:halkyon_learn_server/src/publish/release_repository.dart';
|
||||
import 'package:serverpod/protocol.dart' show CloudStorageEntry;
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
|
@ -14,10 +13,11 @@ import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart
|
|||
/// re-upload everything) and before their identities and the person go.
|
||||
/// Their personal space goes with all its content (the foreign keys cascade
|
||||
/// into every synced table and the sync engine's bookkeeping) and its stored
|
||||
/// files; their profile, what they wrote in discussions, their published
|
||||
/// files; what they wrote in discussions, their published
|
||||
/// studysets, blocks, what they read and the assistants they connected; and
|
||||
/// once the deletion holds, their releases' repositories: what learners
|
||||
/// copied stays theirs (ADR 0005, rule 4).
|
||||
/// copied stays theirs (ADR 0005, rule 4). Their profile is halkyon_base's,
|
||||
/// which deletes it after this (ADR 0016).
|
||||
Future<AfterDeletion?> deleteLearnData(
|
||||
Session session,
|
||||
UuidValue authUserId,
|
||||
|
|
@ -50,18 +50,6 @@ Future<AfterDeletion?> deleteLearnData(
|
|||
transaction: transaction,
|
||||
noReturn: true,
|
||||
);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
// What they wrote in discussions: for good, or kept without them for
|
||||
// others' replies under it (ADR 0013); what they read.
|
||||
await deleteDiscussionsOf(session, authUserId, transaction: transaction);
|
||||
|
|
|
|||
|
|
@ -10,16 +10,23 @@ const upstreamSourceCodeUrl =
|
|||
'https://code.halkyon.app/project-halkyon/halkyon';
|
||||
|
||||
/// How Halkyon Learn sets halkyon_base up (ADR 0014), the module giving every
|
||||
/// Halkyon server its sign-in, the account and its deletion, the pruning and
|
||||
/// the instance's basics: the instance says it serves `learn` (the app accepts
|
||||
/// only such an instance; never change it), sign-in mails name the app, the
|
||||
/// instance names this project's source, works with apps from [minimumAppBuild] on, and deletes
|
||||
/// Learn's own data of a person who goes ([deleteLearnData]). The server sets
|
||||
/// it up before it starts (`server.dart`), and so do tests that delete people.
|
||||
/// Halkyon server its sign-in, the account and its deletion, the pruning, the
|
||||
/// instance's basics and people's public profiles: the instance says it
|
||||
/// serves `learn` (the app accepts only such an instance; never change it),
|
||||
/// sign-in mails name the app, the instance names this project's source,
|
||||
/// works with apps from [minimumAppBuild] on, deletes Learn's own data of a
|
||||
/// person who goes ([deleteLearnData]), and profile pages name the app at
|
||||
/// their foot. The server sets it up before it starts (`server.dart`), and so
|
||||
/// do tests that delete people or read profiles.
|
||||
HalkyonBase learnBase() => HalkyonBase(
|
||||
app: 'learn',
|
||||
appName: 'Halkyon Learn',
|
||||
upstreamSourceCodeUrl: upstreamSourceCodeUrl,
|
||||
minimumAppBuild: minimumAppBuild,
|
||||
deletePersonData: const [deleteLearnData],
|
||||
profilePageNote: (
|
||||
english: 'On Halkyon Learn, a spaced-repetition learning app:',
|
||||
german: 'Auf Halkyon Learn, einer Lern-App mit verteilter Wiederholung:',
|
||||
link: Uri.parse('https://halkyon.app/learn'),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import 'package:halkyon_base_server/base.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';
|
||||
|
||||
// What a person sees of a published studyset's discussions (ADR 0013), and
|
||||
|
|
@ -69,13 +68,8 @@ final class DiscussionReading {
|
|||
for (final posts in reading._inQuestion.values)
|
||||
for (final post in posts) ?post.authUserId,
|
||||
};
|
||||
// SELECT … FROM "public_profile" WHERE "authUserId" = ANY($1)
|
||||
for (final profile in await PublicProfile.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.inSet(people),
|
||||
)) {
|
||||
reading._profiles[profile.authUserId] = profile;
|
||||
}
|
||||
// Their public profiles, which the instance's register keeps (ADR 0016).
|
||||
reading._profiles.addAll(await profilesOf(session, people));
|
||||
// SELECT … FROM "feedback_block" WHERE "authorId" = ANY($1)
|
||||
for (final block in await FeedbackBlock.db.find(
|
||||
session,
|
||||
|
|
@ -108,10 +102,9 @@ final class DiscussionReading {
|
|||
final _inQuestion = <UuidValue, List<DiscussionPost>>{};
|
||||
final _published = <int, PublishedStudyset>{};
|
||||
final _titles = <int, String>{};
|
||||
final _profiles = <UuidValue, PublicProfile>{};
|
||||
final _profiles = <UuidValue, Profile>{};
|
||||
final _blocked = <(UuidValue, UuidValue)>{};
|
||||
final _seen = <UuidValue, DateTime>{};
|
||||
final _avatars = <String, String>{};
|
||||
|
||||
/// The published studyset [post] is on.
|
||||
PublishedStudyset publishedOf(DiscussionPost post) =>
|
||||
|
|
@ -278,14 +271,7 @@ final class DiscussionReading {
|
|||
return FeedbackPerson(
|
||||
handle: profile.handle,
|
||||
displayName: profile.displayName,
|
||||
avatarUrl: switch (profile.avatarHash) {
|
||||
final hash? =>
|
||||
_avatars[hash] ??= (await _session.storage.publicDownloadUrl(
|
||||
storageId: avatarStorage,
|
||||
path: avatarPath(hash),
|
||||
)).toString(),
|
||||
null => null,
|
||||
},
|
||||
avatarUrl: profile.avatarUrl,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import 'dart:io';
|
|||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_learn_server/src/explore/learner_count.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_learn_server/src/profile/profile_rules.dart';
|
||||
import 'package:halkyon_learn_server/src/publish/release_repository.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
|
@ -254,7 +254,10 @@ class ExploreEndpoint extends Endpoint {
|
|||
|
||||
/// The latest release of each published studyset not withdrawn, with its
|
||||
/// author, matching every one of [where] (SQL on `latest`), the newest
|
||||
/// first; a page at a time when [paged].
|
||||
/// first; a page at a time when [paged]. The authors' profiles are joined
|
||||
/// from the profiles' table, which is halkyon_base's: Halkyon Learn holds
|
||||
/// the instance's register wherever it runs (ADR 0016), so its database has
|
||||
/// every profile, and the search over authors stays one query.
|
||||
Future<List<ExploreEntry>> _latest(
|
||||
Session session, {
|
||||
required List<String> where,
|
||||
|
|
|
|||
|
|
@ -141,15 +141,12 @@ class InboxEndpoint extends Endpoint {
|
|||
))
|
||||
block.blockedId,
|
||||
};
|
||||
// SELECT … FROM "public_profile" WHERE "authUserId" = ANY($1)
|
||||
// Their handles, from the instance's register (ADR 0016), in the order
|
||||
// of their lower case.
|
||||
return [
|
||||
for (final profile in await PublicProfile.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.inSet(blocked),
|
||||
orderBy: (t) => t.handleKey,
|
||||
))
|
||||
for (final profile in (await profilesOf(session, blocked)).values)
|
||||
profile.handle,
|
||||
];
|
||||
]..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
|
||||
}
|
||||
|
||||
/// The file [sha256] that the suggestion [threadId] names, for the author
|
||||
|
|
@ -199,17 +196,9 @@ class InboxEndpoint extends Endpoint {
|
|||
}
|
||||
|
||||
/// The person with [handle]; throws [NotFoundException] for none.
|
||||
Future<UuidValue> _personOf(Session session, String handle) async {
|
||||
// SELECT "authUserId" FROM "public_profile" WHERE "handleKey" = $1
|
||||
final profile = await PublicProfile.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.handleKey.equals(handle.trim().toLowerCase()),
|
||||
);
|
||||
if (profile == null) {
|
||||
throw NotFoundException(what: 'person');
|
||||
}
|
||||
return profile.authUserId;
|
||||
}
|
||||
Future<UuidValue> _personOf(Session session, String handle) async =>
|
||||
await personWithHandle(session, handle.trim()) ??
|
||||
(throw NotFoundException(what: 'person'));
|
||||
|
||||
/// Whether the thread's [payload] names the file [sha256] among those of
|
||||
/// its proposed content (`files`).
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import 'dart:typed_data';
|
|||
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_learn_server/src/profile/profile_rules.dart';
|
||||
import 'package:halkyon_learn_server/src/publish/release_license.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
|
@ -92,14 +91,8 @@ Future<List<FeedbackView>> gatherFeedback(
|
|||
...personOf.values,
|
||||
for (final studyset in published.values) studyset.authUserId,
|
||||
};
|
||||
// SELECT … FROM "public_profile" WHERE "authUserId" = ANY($1)
|
||||
final profiles = {
|
||||
for (final profile in await PublicProfile.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.inSet(people),
|
||||
))
|
||||
profile.authUserId: profile,
|
||||
};
|
||||
// Their public profiles, which the instance's register keeps (ADR 0016).
|
||||
final profiles = await profilesOf(session, people);
|
||||
// SELECT … FROM "serverpod_auth_core_user" WHERE "id" = ANY($1)
|
||||
final identifiedPeople = {
|
||||
for (final person in await AuthUser.db.find(
|
||||
|
|
@ -129,20 +122,11 @@ Future<List<FeedbackView>> gatherFeedback(
|
|||
};
|
||||
|
||||
final domain = accountDomain(session.serverpod);
|
||||
final avatars = <String, String?>{};
|
||||
Future<FeedbackPerson> personFor(PublicProfile profile) async =>
|
||||
FeedbackPerson(
|
||||
handle: profile.handle,
|
||||
displayName: profile.displayName,
|
||||
avatarUrl: switch (profile.avatarHash) {
|
||||
final hash? =>
|
||||
avatars[hash] ??= (await session.storage.publicDownloadUrl(
|
||||
storageId: avatarStorage,
|
||||
path: avatarPath(hash),
|
||||
)).toString(),
|
||||
null => null,
|
||||
},
|
||||
);
|
||||
FeedbackPerson personFor(Profile profile) => FeedbackPerson(
|
||||
handle: profile.handle,
|
||||
displayName: profile.displayName,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
);
|
||||
|
||||
final views = <(DateTime, FeedbackView)>[];
|
||||
for (final thread in threads) {
|
||||
|
|
@ -213,7 +197,7 @@ Future<List<FeedbackView>> gatherFeedback(
|
|||
replies.add(
|
||||
FeedbackMessageView(
|
||||
id: message.id!,
|
||||
by: await personFor(by == author ? authorProfile : writerProfile),
|
||||
by: personFor(by == author ? authorProfile : writerProfile),
|
||||
fromAuthor: by == author,
|
||||
payload: text,
|
||||
createdAt: _timeOf(message.id!),
|
||||
|
|
@ -243,7 +227,7 @@ Future<List<FeedbackView>> gatherFeedback(
|
|||
studysetTitle: release.title,
|
||||
location: '$domain/@${authorProfile.handle}/${studyset.slug}',
|
||||
kind: thread.kind,
|
||||
writer: await personFor(writerProfile),
|
||||
writer: personFor(writerProfile),
|
||||
payload: payload,
|
||||
createdAt: createdAt,
|
||||
messages: replies,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
// 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_base_server/halkyon_base_server.dart' as _ik8molhd;
|
||||
import 'package:halkyon_learn_server/src/generated/discussion/discussion_target.dart'
|
||||
as _ike2gc23;
|
||||
|
|
@ -33,7 +32,6 @@ import '../discussion/discussion_endpoint.dart' as _ienplr78;
|
|||
import '../explore/explore_endpoint.dart' as _i897jtwc;
|
||||
import '../feedback/feedback_endpoint.dart' as _i3n4p88h;
|
||||
import '../instance/instance_endpoint.dart' as _i3g8bfc2;
|
||||
import '../profile/profile_endpoint.dart' as _i6ky944g;
|
||||
import '../publish/publish_endpoint.dart' as _iyiav298;
|
||||
export 'future_calls.dart' show ServerpodFutureCallsGetter;
|
||||
|
||||
|
|
@ -101,18 +99,6 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
'instance',
|
||||
null,
|
||||
),
|
||||
'profile': _i6ky944g.ProfileEndpoint()
|
||||
..initialize(
|
||||
server,
|
||||
'profile',
|
||||
null,
|
||||
),
|
||||
'profiles': _i6ky944g.ProfilesEndpoint()
|
||||
..initialize(
|
||||
server,
|
||||
'profiles',
|
||||
null,
|
||||
),
|
||||
'publish': _iyiav298.PublishEndpoint()
|
||||
..initialize(
|
||||
server,
|
||||
|
|
@ -1154,152 +1140,6 @@ 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'],
|
||||
),
|
||||
),
|
||||
'checkLinks': _is.MethodConnector(
|
||||
name: 'checkLinks',
|
||||
params: {},
|
||||
call:
|
||||
(
|
||||
_is.Session session,
|
||||
Map<String, dynamic> params,
|
||||
) async => (endpoints['profile'] as _i6ky944g.ProfileEndpoint)
|
||||
.checkLinks(session),
|
||||
),
|
||||
'checkHandle': _is.MethodConnector(
|
||||
name: 'checkHandle',
|
||||
params: {
|
||||
'handle': _is.ParameterDescription(
|
||||
name: 'handle',
|
||||
type: _is.getType<String>(),
|
||||
nullable: false,
|
||||
),
|
||||
},
|
||||
call:
|
||||
(
|
||||
_is.Session session,
|
||||
Map<String, dynamic> params,
|
||||
) async => (endpoints['profile'] as _i6ky944g.ProfileEndpoint)
|
||||
.checkHandle(
|
||||
session,
|
||||
params['handle'],
|
||||
),
|
||||
),
|
||||
'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'],
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
connectors['publish'] = _is.EndpointConnector(
|
||||
name: 'publish',
|
||||
endpoint: endpoints['publish']!,
|
||||
|
|
|
|||
|
|
@ -1,203 +0,0 @@
|
|||
/* 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.verifiedLinks,
|
||||
this.pageUrl,
|
||||
this.avatarUrl,
|
||||
required this.handleFixed,
|
||||
});
|
||||
|
||||
factory Profile({
|
||||
required String handle,
|
||||
required String address,
|
||||
required String displayName,
|
||||
required String bio,
|
||||
required List<String> links,
|
||||
List<String>? verifiedLinks,
|
||||
String? pageUrl,
|
||||
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'],
|
||||
),
|
||||
verifiedLinks: jsonSerialization['verifiedLinks'] == null
|
||||
? null
|
||||
: _i5lsenq5.Protocol().deserialize<List<String>>(
|
||||
jsonSerialization['verifiedLinks'],
|
||||
),
|
||||
pageUrl: jsonSerialization['pageUrl'] as String?,
|
||||
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;
|
||||
|
||||
/// 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;
|
||||
|
||||
/// 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,
|
||||
List<String>? verifiedLinks,
|
||||
String? pageUrl,
|
||||
String? avatarUrl,
|
||||
bool? handleFixed,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'Profile',
|
||||
'handle': handle,
|
||||
'address': address,
|
||||
'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,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'Profile',
|
||||
'handle': handle,
|
||||
'address': address,
|
||||
'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,
|
||||
};
|
||||
}
|
||||
|
||||
@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,
|
||||
List<String>? verifiedLinks,
|
||||
String? pageUrl,
|
||||
String? avatarUrl,
|
||||
required bool handleFixed,
|
||||
}) : super._(
|
||||
handle: handle,
|
||||
address: address,
|
||||
displayName: displayName,
|
||||
bio: bio,
|
||||
links: links,
|
||||
verifiedLinks: verifiedLinks,
|
||||
pageUrl: pageUrl,
|
||||
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? verifiedLinks = _Undefined,
|
||||
Object? pageUrl = _Undefined,
|
||||
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(),
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
/* 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,
|
||||
|
||||
/// The links were checked too often in the last hour.
|
||||
tooManyLinkChecks;
|
||||
|
||||
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;
|
||||
case 'tooManyLinkChecks':
|
||||
return ProfileRefusal.tooManyLinkChecks;
|
||||
default:
|
||||
throw ArgumentError(
|
||||
'Value "$name" cannot be converted to "ProfileRefusal"',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toJson() => name;
|
||||
|
||||
@override
|
||||
String toString() => name;
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
/* 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,844 +0,0 @@
|
|||
/* 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.verifiedLinks,
|
||||
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,
|
||||
List<String>? verifiedLinks,
|
||||
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'],
|
||||
),
|
||||
verifiedLinks: jsonSerialization['verifiedLinks'] == null
|
||||
? null
|
||||
: _i5lsenq5.Protocol().deserialize<List<String>>(
|
||||
jsonSerialization['verifiedLinks'],
|
||||
),
|
||||
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;
|
||||
|
||||
/// Those of [links] whose pages link back to the profile's page with
|
||||
/// `rel="me"`, as checked when asked (ADR 0011); null for none. A link
|
||||
/// changed, or a handle, loses its check.
|
||||
List<String>? verifiedLinks;
|
||||
|
||||
/// 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,
|
||||
List<String>? verifiedLinks,
|
||||
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 (verifiedLinks != null) 'verifiedLinks': verifiedLinks?.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,
|
||||
List<String>? verifiedLinks,
|
||||
String? avatarHash,
|
||||
DateTime? firstPublishedAt,
|
||||
required DateTime updatedAt,
|
||||
}) : super._(
|
||||
id: id,
|
||||
authUserId: authUserId,
|
||||
handle: handle,
|
||||
handleKey: handleKey,
|
||||
displayName: displayName,
|
||||
bio: bio,
|
||||
links: links,
|
||||
verifiedLinks: verifiedLinks,
|
||||
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? verifiedLinks = _Undefined,
|
||||
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(),
|
||||
verifiedLinks: verifiedLinks is List<String>?
|
||||
? verifiedLinks
|
||||
: this.verifiedLinks?.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<List<String>, List<String>> verifiedLinks(
|
||||
List<String>? value,
|
||||
) => _is.ColumnValue(
|
||||
table.verifiedLinks,
|
||||
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,
|
||||
);
|
||||
verifiedLinks = _is.ColumnSerializable<List<String>>(
|
||||
'verifiedLinks',
|
||||
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;
|
||||
|
||||
/// Those of [links] whose pages link back to the profile's page with
|
||||
/// `rel="me"`, as checked when asked (ADR 0011); null for none. A link
|
||||
/// changed, or a handle, loses its check.
|
||||
late final _is.ColumnSerializable<List<String>> verifiedLinks;
|
||||
|
||||
/// 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,
|
||||
verifiedLinks,
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -91,10 +91,6 @@ 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 'publish/publication.dart' as _i9o1kyhh;
|
||||
import 'publish/publish_refusal.dart' as _iofgvwaa;
|
||||
import 'publish/publish_refused_exception.dart' as _itdn8966;
|
||||
|
|
@ -161,10 +157,6 @@ 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 'publish/publication.dart';
|
||||
export 'publish/publish_refusal.dart';
|
||||
export 'publish/publish_refused_exception.dart';
|
||||
|
|
@ -3224,111 +3216,6 @@ 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: 'verifiedLinks',
|
||||
columnType: _isp.ColumnType.json,
|
||||
isNullable: true,
|
||||
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: 'published_studyset',
|
||||
dartName: 'PublishedStudyset',
|
||||
|
|
@ -4112,18 +3999,6 @@ 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 == _i9o1kyhh.Publication) {
|
||||
return _i9o1kyhh.Publication.fromJson(data) as T;
|
||||
}
|
||||
|
|
@ -4376,23 +4251,6 @@ 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<_i9o1kyhh.Publication?>()) {
|
||||
return (data != null ? _i9o1kyhh.Publication.fromJson(data) : null) as T;
|
||||
}
|
||||
|
|
@ -4471,12 +4329,6 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
.toList()
|
||||
as T;
|
||||
}
|
||||
if (t == _is.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))
|
||||
|
|
@ -4623,10 +4475,6 @@ 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',
|
||||
_i9o1kyhh.Publication => 'Publication',
|
||||
_iofgvwaa.PublishRefusal => 'PublishRefusal',
|
||||
_itdn8966.PublishRefusedException => 'PublishRefusedException',
|
||||
|
|
@ -4763,14 +4611,6 @@ 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 _i9o1kyhh.Publication():
|
||||
return 'Publication';
|
||||
case _iofgvwaa.PublishRefusal():
|
||||
|
|
@ -4992,18 +4832,6 @@ 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 == 'Publication') {
|
||||
return deserialize<_i9o1kyhh.Publication>(data['data']);
|
||||
}
|
||||
|
|
@ -5162,8 +4990,6 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
return _i4l20c7i.PdfReading.t;
|
||||
case _iz838ro9.PdfSource:
|
||||
return _iz838ro9.PdfSource.t;
|
||||
case _ioppe2ep.PublicProfile:
|
||||
return _ioppe2ep.PublicProfile.t;
|
||||
case _irp51t0o.PublishedStudyset:
|
||||
return _irp51t0o.PublishedStudyset.t;
|
||||
case _i6qpha3x.StudysetRelease:
|
||||
|
|
|
|||
|
|
@ -52,16 +52,6 @@ inbox:
|
|||
- file:
|
||||
instance:
|
||||
- info:
|
||||
profile:
|
||||
- mine:
|
||||
- save:
|
||||
- checkLinks:
|
||||
- checkHandle:
|
||||
- setAvatar:
|
||||
- removeAvatar:
|
||||
- remove:
|
||||
profiles:
|
||||
- byHandle:
|
||||
publish:
|
||||
- publication:
|
||||
- published:
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import 'package:halkyon_base_server/base.dart';
|
|||
import 'package:halkyon_learn_server/src/assistant/connector_routes.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_learn_server/src/instance/jellyfin_suggestion.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.
|
||||
|
|
|
|||
|
|
@ -1,288 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:html/parser.dart' as html;
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
||||
// A profile's links are verified as Mastodon verifies them (ADR 0011): the
|
||||
// page a link points to links back to the person's profile page with
|
||||
// `rel="me"`. Checking means the instance fetches pages people name, so it
|
||||
// fetches only HTTPS pages on public addresses, the address it checked being
|
||||
// the one it connects to, a little of each, briefly.
|
||||
|
||||
/// A page as fetched: where it ended up after redirects, and its HTML.
|
||||
typedef FetchedPage = ({Uri url, String html});
|
||||
|
||||
/// Fetches the pages profile links point to. Tests answer with pages of
|
||||
/// their own.
|
||||
abstract interface class LinkFetcher {
|
||||
/// The HTML page at [link]; null when it can't be had, or mustn't be.
|
||||
Future<FetchedPage?> fetch(Uri link);
|
||||
}
|
||||
|
||||
/// How this instance fetches the pages profile links point to: tests put
|
||||
/// their own [fetcher].
|
||||
final class LinkChecks {
|
||||
LinkChecks._(this.fetcher);
|
||||
|
||||
factory LinkChecks.of(Serverpod pod) => _ofPod[pod] ??= LinkChecks._(
|
||||
SafeLinkFetcher(
|
||||
userAgent:
|
||||
'Halkyon Learn (+https://${pod.config.apiServer.publicHost}; '
|
||||
'checks rel="me" back-links of profiles)',
|
||||
),
|
||||
);
|
||||
|
||||
static final _ofPod = Expando<LinkChecks>('Link checks');
|
||||
|
||||
/// What fetches the pages.
|
||||
LinkFetcher fetcher;
|
||||
}
|
||||
|
||||
/// Those of [links] whose pages link back to [profileUrl] with `rel="me"`,
|
||||
/// in their order; the pages fetched at once.
|
||||
Future<List<String>> verifiedLinksOf(
|
||||
LinkFetcher fetcher,
|
||||
List<String> links, {
|
||||
required Uri profileUrl,
|
||||
}) async {
|
||||
final linkingBack = await Future.wait([
|
||||
for (final link in links)
|
||||
switch (Uri.tryParse(link)) {
|
||||
final uri? =>
|
||||
fetcher
|
||||
.fetch(uri)
|
||||
.then(
|
||||
(page) =>
|
||||
page != null &&
|
||||
linksBackTo(
|
||||
page.html,
|
||||
pageUrl: page.url,
|
||||
profileUrl: profileUrl,
|
||||
),
|
||||
),
|
||||
null => Future.value(false),
|
||||
},
|
||||
]);
|
||||
return [
|
||||
for (final (i, link) in links.indexed)
|
||||
if (linkingBack[i]) link,
|
||||
];
|
||||
}
|
||||
|
||||
/// Whether [page], the HTML at [pageUrl], links back to [profileUrl]: an
|
||||
/// `<a>` or `<link>` whose `rel` holds `me` and whose address, resolved on
|
||||
/// the page, is the profile page's, compared without case (hosts and handles
|
||||
/// have none), a trailing slash, a query or a fragment.
|
||||
bool linksBackTo(
|
||||
String page, {
|
||||
required Uri pageUrl,
|
||||
required Uri profileUrl,
|
||||
}) {
|
||||
final wanted = _comparable(profileUrl);
|
||||
for (final element
|
||||
in html.parse(page).querySelectorAll('a[rel], link[rel]')) {
|
||||
final rel = element.attributes['rel']!.toLowerCase().split(RegExp(r'\s+'));
|
||||
final href = element.attributes['href'];
|
||||
if (!rel.contains('me') || href == null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (_comparable(pageUrl.resolve(href.trim())) == wanted) {
|
||||
return true;
|
||||
}
|
||||
} on FormatException {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// [url] as profile pages compare.
|
||||
String _comparable(Uri url) {
|
||||
var path = url.path.toLowerCase();
|
||||
while (path.endsWith('/')) {
|
||||
path = path.substring(0, path.length - 1);
|
||||
}
|
||||
final port = url.hasPort ? ':${url.port}' : '';
|
||||
return '${url.scheme.toLowerCase()}://${url.host.toLowerCase()}$port$path';
|
||||
}
|
||||
|
||||
/// Whether [address] is one anybody on the internet reaches: never the
|
||||
/// instance itself or a network it is on (loopback, private, link-local,
|
||||
/// shared, unique local), nor an address kept for special uses
|
||||
/// (documentation, benchmarking, multicast, reserved), nor such an IPv4
|
||||
/// address inside an IPv6 one.
|
||||
bool isPublicAddress(InternetAddress address) {
|
||||
final bytes = address.rawAddress;
|
||||
switch (address.type) {
|
||||
case InternetAddressType.IPv4:
|
||||
return _isPublicV4(bytes);
|
||||
case InternetAddressType.IPv6:
|
||||
// IPv4-mapped (::ffff:0:0/96) and NAT64 (64:ff9b::/96): as the IPv4
|
||||
// address inside.
|
||||
if (_startsWith(bytes, const [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255]) ||
|
||||
_startsWith(bytes, const [
|
||||
0,
|
||||
0x64,
|
||||
0xff,
|
||||
0x9b,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
])) {
|
||||
return _isPublicV4(bytes.sublist(12));
|
||||
}
|
||||
// 6to4 (2002::/16): as the IPv4 address it carries.
|
||||
if (_startsWith(bytes, const [0x20, 0x02])) {
|
||||
return _isPublicV4(bytes.sublist(2, 6));
|
||||
}
|
||||
// Only global unicast (2000::/3), without the IETF's protocol
|
||||
// assignments (2001::/23) and documentation (2001:db8::/32).
|
||||
return bytes[0] & 0xe0 == 0x20 &&
|
||||
!(bytes[0] == 0x20 && bytes[1] == 0x01 && bytes[2] < 0x02) &&
|
||||
!_startsWith(bytes, const [0x20, 0x01, 0x0d, 0xb8]);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isPublicV4(List<int> bytes) {
|
||||
final [a, b, c, _] = bytes;
|
||||
return !(a == 0 || // this network
|
||||
a == 10 || // private
|
||||
a == 127 || // loopback
|
||||
a >= 224 || // multicast, reserved, broadcast
|
||||
(a == 100 && b >= 64 && b < 128) || // shared address space
|
||||
(a == 169 && b == 254) || // link-local
|
||||
(a == 172 && b >= 16 && b < 32) || // private
|
||||
(a == 192 && b == 168) || // private
|
||||
(a == 192 && b == 0 && (c == 0 || c == 2)) || // protocols, documentation
|
||||
(a == 192 && b == 88 && c == 99) || // 6to4 relays
|
||||
(a == 198 && (b == 18 || b == 19)) || // benchmarking
|
||||
(a == 198 && b == 51 && c == 100) || // documentation
|
||||
(a == 203 && b == 0 && c == 113)); // documentation
|
||||
}
|
||||
|
||||
bool _startsWith(List<int> bytes, List<int> prefix) {
|
||||
for (final (i, byte) in prefix.indexed) {
|
||||
if (bytes[i] != byte) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// [LinkFetcher] over HTTPS on port 443 only, to public addresses only: the
|
||||
/// host's addresses are looked up and checked ([isPublicAddress]) before
|
||||
/// connecting, and the connection goes to the address checked, the
|
||||
/// certificate verified for the host, so no lookup in between can send it
|
||||
/// elsewhere. At most [maxRedirects] redirects, each checked anew, and
|
||||
/// [maxBytes] of an HTML page within [timeout]; no proxy, no cookies.
|
||||
final class SafeLinkFetcher implements LinkFetcher {
|
||||
SafeLinkFetcher({required this.userAgent});
|
||||
|
||||
/// How the instance names itself to the sites it asks.
|
||||
final String userAgent;
|
||||
|
||||
static const maxRedirects = 3;
|
||||
static const maxBytes = 1024 * 1024;
|
||||
static const timeout = Duration(seconds: 10);
|
||||
|
||||
@override
|
||||
Future<FetchedPage?> fetch(Uri link) async {
|
||||
if (!_fetchable(link)) {
|
||||
return null;
|
||||
}
|
||||
final client = HttpClient()
|
||||
..userAgent = userAgent
|
||||
..connectionTimeout = const Duration(seconds: 5)
|
||||
..findProxy = ((_) => 'DIRECT')
|
||||
..connectionFactory = _connect;
|
||||
try {
|
||||
return await _fetch(client, link).timeout(timeout);
|
||||
} on Object {
|
||||
// Unreachable, not public, not HTML, too slow, too large: unchecked.
|
||||
return null;
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<FetchedPage?> _fetch(HttpClient client, Uri link) async {
|
||||
var url = link;
|
||||
for (var redirects = 0; redirects <= maxRedirects; redirects++) {
|
||||
if (!_fetchable(url)) {
|
||||
return null;
|
||||
}
|
||||
final request = await client.getUrl(url);
|
||||
request
|
||||
..followRedirects = false
|
||||
..headers.set(
|
||||
HttpHeaders.acceptHeader,
|
||||
'text/html, application/xhtml+xml',
|
||||
);
|
||||
final response = await request.close();
|
||||
if (const {301, 302, 303, 307, 308}.contains(response.statusCode)) {
|
||||
final location = response.headers.value(HttpHeaders.locationHeader);
|
||||
await response.drain<void>();
|
||||
if (location == null) {
|
||||
return null;
|
||||
}
|
||||
url = url.resolve(location);
|
||||
continue;
|
||||
}
|
||||
final type = response.headers.contentType?.mimeType;
|
||||
if (response.statusCode != 200 ||
|
||||
!(type == 'text/html' || type == 'application/xhtml+xml')) {
|
||||
await response.drain<void>();
|
||||
return null;
|
||||
}
|
||||
final bytes = BytesBuilder(copy: false);
|
||||
await for (final chunk in response) {
|
||||
bytes.add(chunk);
|
||||
if (bytes.length > maxBytes) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return (
|
||||
url: url,
|
||||
html: utf8.decode(bytes.takeBytes(), allowMalformed: true),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool _fetchable(Uri url) =>
|
||||
url.isScheme('https') &&
|
||||
url.host.isNotEmpty &&
|
||||
url.port == 443 &&
|
||||
url.userInfo.isEmpty;
|
||||
|
||||
/// A TLS connection to [url]'s host, through the address checked.
|
||||
static Future<ConnectionTask<Socket>> _connect(
|
||||
Uri url,
|
||||
String? proxyHost,
|
||||
int? proxyPort,
|
||||
) async {
|
||||
final literal = InternetAddress.tryParse(url.host);
|
||||
final addresses = literal != null
|
||||
? [literal]
|
||||
: await InternetAddress.lookup(url.host);
|
||||
if (addresses.isEmpty || !addresses.every(isPublicAddress)) {
|
||||
throw SocketException('${url.host} is no public address.');
|
||||
}
|
||||
final task = await Socket.startConnect(addresses.first, url.port);
|
||||
return ConnectionTask.fromSocket(
|
||||
task.socket.then((socket) => SecureSocket.secure(socket, host: url.host)),
|
||||
task.cancel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
### 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>
|
||||
### Those of [links] whose pages link back to [pageUrl] with `rel="me"`
|
||||
### (ADR 0011). Null from servers that predate it: none.
|
||||
verifiedLinks: List<String>?
|
||||
### 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.
|
||||
pageUrl: String?
|
||||
### Where their avatar is, a PNG; null for none.
|
||||
avatarUrl: String?
|
||||
### Whether the handle is fixed, as its person has published.
|
||||
handleFixed: bool
|
||||
|
|
@ -1,332 +0,0 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_learn_server/src/profile/link_verification.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();
|
||||
// Checks stay with the links left as they were; a new handle moves
|
||||
// the page they linked back to, and drops them all.
|
||||
final stillVerified = [
|
||||
if (mine != null && mine.handleKey == key)
|
||||
for (final link in mine.verifiedLinks ?? const <String>[])
|
||||
if (trimmed.links.contains(link)) link,
|
||||
];
|
||||
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,
|
||||
verifiedLinks: stillVerified,
|
||||
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,
|
||||
verifiedLinks: stillVerified,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
Future<Profile> checkLinks(Session session) async {
|
||||
final person = session.authenticated!.authUserId;
|
||||
if (!await _linkChecks.tryRecordAttempt(session, key: person.uuid)) {
|
||||
throw ProfileRefusedException(reason: ProfileRefusal.tooManyLinkChecks);
|
||||
}
|
||||
// SELECT … FROM "public_profile" WHERE "authUserId" = $1
|
||||
final mine = await PublicProfile.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
);
|
||||
if (mine == null) {
|
||||
throw NotFoundException(what: 'profile');
|
||||
}
|
||||
final verified = await verifiedLinksOf(
|
||||
LinkChecks.of(session.serverpod).fetcher,
|
||||
mine.links,
|
||||
profileUrl: profilePageUrl(session.serverpod, mine.handle),
|
||||
);
|
||||
// UPDATE "public_profile" SET "verifiedLinks" = $1
|
||||
// WHERE "id" = $2 AND "handleKey" = $3 (unless the page moved
|
||||
// meanwhile; links changed meanwhile show only if still there)
|
||||
final checked = await PublicProfile.db.updateWhere(
|
||||
session,
|
||||
columnValues: (t) => [t.verifiedLinks(verified)],
|
||||
where: (t) => t.id.equals(mine.id) & t.handleKey.equals(mine.handleKey),
|
||||
);
|
||||
return await profileOf(session, checked.firstOrNull ?? mine);
|
||||
}
|
||||
|
||||
/// How often a person's links are checked: each check fetches every page
|
||||
/// they link.
|
||||
static final _linkChecks = DatabaseRateLimiter(
|
||||
RateLimiterConfig(
|
||||
domain: 'profile',
|
||||
source: 'link_checks',
|
||||
maxAttempts: 10,
|
||||
timeframe: const Duration(hours: 1),
|
||||
),
|
||||
);
|
||||
|
||||
/// 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
|
||||
/// can. For the app to say so while it is typed (decided 2026-10-12);
|
||||
/// saving checks again. Whose a handle is, anyone can find out already
|
||||
/// ([ProfilesEndpoint.byHandle]), so this tells nothing new.
|
||||
Future<ProfileRefusal?> checkHandle(Session session, String handle) async {
|
||||
if (profileRefusal(
|
||||
handle: handle,
|
||||
displayName: '',
|
||||
bio: '',
|
||||
links: const [],
|
||||
)
|
||||
case final reason?) {
|
||||
return reason;
|
||||
}
|
||||
final person = session.authenticated!.authUserId;
|
||||
final key = handle.toLowerCase();
|
||||
// SELECT … FROM "public_profile" WHERE "authUserId" = $1
|
||||
final mine = await PublicProfile.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
);
|
||||
if (mine != null &&
|
||||
mine.firstPublishedAt != null &&
|
||||
mine.handleKey != key) {
|
||||
return 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),
|
||||
);
|
||||
return theirs == null ? null : ProfileRefusal.handleTaken;
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_learn_server/src/profile/profile_rules.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
||||
// A person's profile on the web (ADR 0011, decided 2026-10-15): the page their
|
||||
// other pages link back to with `rel="me"`, at the root of the account domain
|
||||
// (`https://halkyon.eu/@raisondetredev`), so the address belongs to the
|
||||
// identity rather than to one service, and stays when the identity is served
|
||||
// from elsewhere. It shows what the app's profile sheet shows; no JavaScript,
|
||||
// no cookies, nothing fetched but the avatar.
|
||||
|
||||
/// The page asked for at [segment] (the path's one segment, `@handle`) on
|
||||
/// [host]: the profile, in the first of English and German [acceptLanguage]
|
||||
/// names (English otherwise); a redirection (`location`) to its own address
|
||||
/// when asked for on another host; not found for anything else.
|
||||
Future<({int status, String html, String? location})> profilePage(
|
||||
Session session,
|
||||
String segment, {
|
||||
required String host,
|
||||
String? acceptLanguage,
|
||||
}) async {
|
||||
final german = _prefersGerman(acceptLanguage);
|
||||
final handle = segment.startsWith('@') ? segment.substring(1) : null;
|
||||
// SELECT … FROM "public_profile" WHERE "handleKey" = $1
|
||||
final row = handle == null || !isHandle(handle)
|
||||
? null
|
||||
: await PublicProfile.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.handleKey.equals(handle.toLowerCase()),
|
||||
);
|
||||
if (row == null) {
|
||||
return (
|
||||
status: 404,
|
||||
html: _notFound(german: german),
|
||||
location: null,
|
||||
);
|
||||
}
|
||||
final own = profilePageUrl(session.serverpod, row.handle);
|
||||
if (host.split(':').first.toLowerCase() != own.host.toLowerCase()) {
|
||||
return (status: 308, html: '', location: '$own');
|
||||
}
|
||||
return (
|
||||
status: 200,
|
||||
html: _page(await profileOf(session, row), german: german),
|
||||
location: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Serves profile pages on [pod]'s web server, at `/@handle`: one path
|
||||
/// segment, which literal routes (the assistant connector's) come before.
|
||||
/// Caddy forwards `/@…` to it (`deploy/Caddyfile`).
|
||||
void addProfilePageRoute(Serverpod pod) {
|
||||
if (pod.config.webServer == null) {
|
||||
return;
|
||||
}
|
||||
pod.webServer.addRoute(_ProfilePageRoute(), '/*');
|
||||
}
|
||||
|
||||
class _ProfilePageRoute() extends Route {
|
||||
this : super(methods: {Method.get, Method.head});
|
||||
|
||||
@override
|
||||
Future<Result> handleCall(Session session, Request request) async {
|
||||
final segments = request.url.pathSegments;
|
||||
final page = await profilePage(
|
||||
session,
|
||||
segments.length == 1 ? segments.single : '',
|
||||
host: request.headers.host?.host ?? '',
|
||||
acceptLanguage: request.headers[HttpHeaders.acceptLanguageHeader]?.join(
|
||||
',',
|
||||
),
|
||||
);
|
||||
if (page.location case final location?) {
|
||||
return Response(
|
||||
page.status,
|
||||
headers: Headers.fromMap({
|
||||
HttpHeaders.locationHeader: [location],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Response(
|
||||
page.status,
|
||||
body: Body.fromString(page.html, mimeType: MimeType.html),
|
||||
headers: Headers.fromMap({
|
||||
// Nothing runs, and only the avatar's picture is fetched.
|
||||
'content-security-policy': [
|
||||
"default-src 'none'; img-src https: http:; style-src 'unsafe-inline'",
|
||||
],
|
||||
'x-content-type-options': ['nosniff'],
|
||||
'referrer-policy': ['no-referrer'],
|
||||
HttpHeaders.cacheControlHeader: ['public, max-age=300'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether German comes before English in [acceptLanguage].
|
||||
bool _prefersGerman(String? acceptLanguage) {
|
||||
final languages = [
|
||||
for (final part in (acceptLanguage ?? '').split(','))
|
||||
if (part.trim().isNotEmpty)
|
||||
(
|
||||
language: part.split(';').first.trim().split('-').first.toLowerCase(),
|
||||
weight: switch (RegExp(r';\s*q=([0-9.]+)').firstMatch(part)) {
|
||||
final match? => double.tryParse(match[1]!) ?? 0,
|
||||
null => 1.0,
|
||||
},
|
||||
),
|
||||
]..sort((a, b) => b.weight.compareTo(a.weight));
|
||||
return languages
|
||||
.where((each) => each.language == 'de' || each.language == 'en')
|
||||
.firstOrNull
|
||||
?.language ==
|
||||
'de';
|
||||
}
|
||||
|
||||
const _text = HtmlEscape(HtmlEscapeMode.element);
|
||||
const _attribute = HtmlEscape(HtmlEscapeMode.attribute);
|
||||
|
||||
String _page(Profile profile, {required bool german}) {
|
||||
final name = profile.displayName.isEmpty
|
||||
? '@${profile.address}'
|
||||
: profile.displayName;
|
||||
final verified = {...?profile.verifiedLinks};
|
||||
final page = StringBuffer()
|
||||
..writeln('<!doctype html>')
|
||||
..writeln('<html lang="${german ? 'de' : 'en'}">')
|
||||
..writeln('<head>')
|
||||
..writeln('<meta charset="utf-8">')
|
||||
..writeln(
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
||||
)
|
||||
// People choose to be found in the app, not by search engines.
|
||||
..writeln('<meta name="robots" content="noindex">')
|
||||
..writeln(
|
||||
'<title>${_text.convert(name)} (@${_text.convert(profile.address)})'
|
||||
'</title>',
|
||||
);
|
||||
for (final link in profile.links) {
|
||||
page.writeln('<link rel="me" href="${_attribute.convert(link)}">');
|
||||
}
|
||||
page
|
||||
..writeln('<style>$_style</style>')
|
||||
..writeln('</head>')
|
||||
..writeln('<body>')
|
||||
..writeln('<main>');
|
||||
if (profile.avatarUrl case final avatar?) {
|
||||
page.writeln(
|
||||
'<img class="avatar" src="${_attribute.convert(avatar)}" alt="" '
|
||||
'width="96" height="96">',
|
||||
);
|
||||
}
|
||||
page
|
||||
..writeln('<h1>${_text.convert(name)}</h1>')
|
||||
..writeln('<p class="address">@${_text.convert(profile.address)}</p>');
|
||||
if (profile.bio.isNotEmpty) {
|
||||
page.writeln(
|
||||
'<p class="bio">'
|
||||
'${_text.convert(profile.bio).replaceAll('\n', '<br>')}</p>',
|
||||
);
|
||||
}
|
||||
if (profile.links.isNotEmpty) {
|
||||
page.writeln('<ul class="links">');
|
||||
for (final link in profile.links) {
|
||||
page.writeln(
|
||||
'<li><a rel="me noopener" href="${_attribute.convert(link)}">'
|
||||
'${_text.convert(_shown(link))}</a>'
|
||||
'${verified.contains(link) ? ' <span class="verified">✓ '
|
||||
'${german ? 'geprüft' : 'verified'}</span>' : ''}'
|
||||
'</li>',
|
||||
);
|
||||
}
|
||||
page.writeln('</ul>');
|
||||
}
|
||||
page
|
||||
..writeln(
|
||||
german
|
||||
? '<p class="app">Auf Halkyon Learn, einer Lern-App mit verteilter '
|
||||
'Wiederholung: <a href="https://halkyon.app/learn">'
|
||||
'halkyon.app/learn</a></p>'
|
||||
: '<p class="app">On Halkyon Learn, a spaced-repetition learning '
|
||||
'app: <a href="https://halkyon.app/learn">halkyon.app/learn'
|
||||
'</a></p>',
|
||||
)
|
||||
..writeln('</main>')
|
||||
..writeln('</body>')
|
||||
..writeln('</html>');
|
||||
return page.toString();
|
||||
}
|
||||
|
||||
/// [link] as a person reads it: without its scheme and a trailing slash.
|
||||
String _shown(String link) =>
|
||||
link.replaceFirst(RegExp('^https?://'), '').replaceFirst(RegExp(r'/$'), '');
|
||||
|
||||
String _notFound({required bool german}) =>
|
||||
'<!doctype html>\n<html lang="${german ? 'de' : 'en'}">\n<head>\n'
|
||||
'<meta charset="utf-8">\n<meta name="robots" content="noindex">\n'
|
||||
'<title>${german ? 'Nicht gefunden' : 'Not found'}</title>\n'
|
||||
'<style>$_style</style>\n</head>\n<body>\n<main>\n'
|
||||
'<h1>${german ? 'Dieses Profil gibt es nicht.' : "This profile doesn't exist."}'
|
||||
'</h1>\n</main>\n</body>\n</html>\n';
|
||||
|
||||
/// The page's look: the brand's cobalt, light and dark as the system is.
|
||||
const _style =
|
||||
':root{color-scheme:light dark;--ink:#1b1b1f;--muted:#5d5e66;'
|
||||
'--paper:#fbf8fd;--accent:#273eb2}'
|
||||
'@media (prefers-color-scheme:dark){:root{--ink:#e4e1e6;--muted:#c6c5d0;'
|
||||
'--paper:#131316;--accent:#b9c3ff}}'
|
||||
'body{margin:0;background:var(--paper);color:var(--ink);'
|
||||
'font:16px/1.5 system-ui,sans-serif}'
|
||||
'main{max-width:36rem;margin:0 auto;padding:3rem 1rem}'
|
||||
'.avatar{border-radius:50%}h1{margin:.5rem 0 0;font-size:1.75rem}'
|
||||
'.address,.app{color:var(--muted)}.address{margin:0 0 1rem}'
|
||||
'.links{padding:0;list-style:none}.links li{margin:.25rem 0}'
|
||||
'a{color:var(--accent)}.verified{color:var(--muted);font-size:.875rem}'
|
||||
'.app{margin-top:2rem;font-size:.875rem}';
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
### 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
|
||||
### The links were checked too often in the last hour.
|
||||
- tooManyLinkChecks
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue