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
36 changed files with 38851 additions and 1197 deletions
|
|
@ -2,12 +2,14 @@
|
|||
|
||||
What every Halkyon server has, as a Serverpod module (`halkyon_base_server`, with its
|
||||
generated client `halkyon_base_client`): signing in, the account's ways in and its
|
||||
deletion, the pruning of what nobody can return to, and what an instance tells its apps
|
||||
before anyone signs in. It comes from Halkyon Learn's server, whose rules it keeps.
|
||||
deletion, the pruning of what nobody can return to, what an instance tells its apps
|
||||
before anyone signs in, and people's public profiles with their pages on the web, which
|
||||
the instance's register keeps (ADR 0016). It comes from Halkyon Learn's server, whose
|
||||
rules it keeps.
|
||||
|
||||
## What it provides
|
||||
|
||||
Endpoints, under the nickname `base` in a host's client (`client.base.emailCode`):
|
||||
Endpoints, under the nickname `base` in a host's client (`client.modules.base.emailCode`):
|
||||
|
||||
| Endpoint | Methods | Who may call |
|
||||
|---|---|---|
|
||||
|
|
@ -18,18 +20,33 @@ Endpoints, under the nickname `base` in a host's client (`client.base.emailCode`
|
|||
| `emailSignIn` | `signIn`: a session for the person a switch token names | without a session |
|
||||
| `account` | `delete`, `signInWays` | signed in |
|
||||
| `instance` | `basics`: the app it serves, GitHub's client ID if any, whether email sign-in is on, the source link, the oldest app build, where data and backups are | anyone |
|
||||
| `profile` | `mine`, `save`, `checkHandle`, `checkLinks` (at most 10 an hour), `setAvatar`, `removeAvatar`, `remove`: the caller's own public profile | `identified` |
|
||||
| `profiles` | `byHandle`: anyone's public profile, whatever the handle's case | signed in |
|
||||
|
||||
- GitHub keeps only the numeric GitHub ID, in Serverpod's own table; email keeps only
|
||||
HMAC-SHA256 of the address, keyed with `emailSignInPepper` (`email_identity`), and its
|
||||
codes as Argon2id hashes (`email_code_request`). Codes are eight Crockford characters,
|
||||
valid 15 minutes, 3 tries; at most 3 an hour per address and 10 per network.
|
||||
- Refusals are `GitHubSignInRefusedException` and `EmailSignInRefusedException`, each with a
|
||||
reason.
|
||||
- Profiles: a handle as Mastodon's (1 to 30 letters, digits or underscores, unique
|
||||
whatever their case, a few reserved for the instance), fixed once the host says so
|
||||
(`fixHandle`; Halkyon Learn at the first publish); a display name, a bio, up to 4
|
||||
links verified as Mastodon verifies them (`rel="me"` back to the profile's page,
|
||||
fetched over HTTPS from public addresses only), and an avatar, a PNG of at most 1 MB
|
||||
in the public storage at `avatars/<SHA-256>.png`. The page is at
|
||||
`https://<account domain>/@handle` (`ACCOUNT_DOMAIN`, else the server's public host),
|
||||
served by `addProfilePageRoute`, in English or German, its foot what the host's
|
||||
`profilePageNote` says.
|
||||
- Refusals are `GitHubSignInRefusedException`, `EmailSignInRefusedException` and
|
||||
`ProfileRefusedException`, each with a reason; `ProfileNotFoundException` where there is
|
||||
no profile.
|
||||
- For the host's own code, `package:halkyon_base_server/base.dart`: the configuration,
|
||||
`tokenManagerBuilders` and `identityProviderBuilders`, the `identified` scope and
|
||||
`grantIdentified`, `deletePerson`, the pruning (`pruneAbandonedAnonymousPeople`,
|
||||
`forgetOldSignUpAttempts`, `forgetExpiredEmailCodes`), short codes, `sourceCodeUrl`,
|
||||
`dataLocation`, `backupLocation`, and the models.
|
||||
`dataLocation`, `backupLocation`, people's profiles as the host's code reads them
|
||||
(`profileOf`, `profilesOf`, `personWithHandle`, `fixHandle`; `saveProfile` for its
|
||||
tests), `accountDomain`, `profilePageUrl`, `addProfilePageRoute`, the register
|
||||
(`AccountRegister`), and the models. A host never reads the profiles' table itself.
|
||||
|
||||
## Taking it into a host
|
||||
|
||||
|
|
@ -63,6 +80,11 @@ Endpoints, under the nickname `base` in a host's client (`client.base.emailCode`
|
|||
upstreamSourceCodeUrl: 'https://code.halkyon.app/project-halkyon/halkyon_todo',
|
||||
minimumAppBuild: 1, // raise it in the commit that breaks older apps
|
||||
deletePersonData: [deleteTodosOf], // the host's own data of a person
|
||||
profilePageNote: ( // what profile pages say of the app at their foot
|
||||
english: 'On Halkyon Todo, a to-do list:',
|
||||
german: 'Auf Halkyon Todo, einer Aufgabenliste:',
|
||||
link: Uri.parse('https://halkyon.app/todo'),
|
||||
),
|
||||
),
|
||||
);
|
||||
pod.initializeAuthServices(
|
||||
|
|
@ -71,6 +93,9 @@ Endpoints, under the nickname `base` in a host's client (`client.base.emailCode`
|
|||
);
|
||||
```
|
||||
|
||||
Serve the profile pages on its web server: `addProfilePageRoute(pod)`, after any
|
||||
literal routes of its own.
|
||||
|
||||
Then schedule the pruning in a future call of the host's own: daily
|
||||
`pruneAbandonedAnonymousPeople`, hourly `forgetOldSignUpAttempts` and
|
||||
`forgetExpiredEmailCodes`.
|
||||
|
|
@ -90,7 +115,8 @@ The passwords it reads: `serverSideSessionKeyHashPepper`; for GitHub `githubClie
|
|||
`githubClientSecret`; for email `emailSignInPepper` and `emailSmtpHost`,
|
||||
`emailSmtpPort`, `emailSmtpUser`, `emailSmtpPassword`, `emailFrom`. Never change either
|
||||
pepper once people sign in: every session or address hash would be lost. The environment:
|
||||
`SOURCE_URL`, `DATA_LOCATION`, `BACKUP_LOCATION`: where the instance keeps people's data
|
||||
`ACCOUNT_DOMAIN`, the domain handles are on, which can never change once anyone follows
|
||||
one; `SOURCE_URL`, `DATA_LOCATION`, `BACKUP_LOCATION`: where the instance keeps people's data
|
||||
and its backups, each `EU`, a country's code (`DE`) or a subdivision's (ISO 3166-2,
|
||||
`US-CA`, `DE-BY`), the backups also `none`; where the disks are, not which law governs.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
/// halkyon_base for a host's own code: its configuration
|
||||
/// (`HalkyonBase.configure`), how people authenticate, the account's
|
||||
/// deletion and the pruning, email sign-in's helpers, the instance's
|
||||
/// basics, and the module's models.
|
||||
/// basics, people's public profiles and their pages, the instance's register
|
||||
/// (ADR 0016), and the module's models.
|
||||
library;
|
||||
|
||||
export 'src/auth/account.dart';
|
||||
|
|
@ -16,3 +17,17 @@ export 'src/generated/protocol.dart' hide Protocol;
|
|||
export 'src/halkyon_base.dart';
|
||||
export 'src/instance/data_location.dart';
|
||||
export 'src/instance/source_code.dart';
|
||||
export 'src/profile/link_verification.dart';
|
||||
export 'src/profile/profile_page.dart' show addProfilePageRoute, profilePage;
|
||||
export 'src/profile/profile_rules.dart'
|
||||
show
|
||||
accountDomain,
|
||||
avatarPath,
|
||||
avatarStorage,
|
||||
isHandle,
|
||||
maxAvatarBytes,
|
||||
operatorAccountDomain,
|
||||
profilePageUrl,
|
||||
reservedHandles;
|
||||
export 'src/profile/profiles.dart';
|
||||
export 'src/register/account_register.dart';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:halkyon_base_server/src/auth/auth_config.dart';
|
||||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_base_server/src/halkyon_base.dart';
|
||||
import 'package:halkyon_base_server/src/profile/profile_rules.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
||||
|
|
@ -8,8 +9,9 @@ import 'package:serverpod_auth_idp_server/core.dart';
|
|||
/// this order: their sessions end first, since a device with a valid session
|
||||
/// would upload everything again; then the host deletes their data in its
|
||||
/// own tables ([HalkyonBase.deletePersonData], in the order given); then
|
||||
/// their address's hash and the person with their other identities. Once the
|
||||
/// transaction holds, what the host's deletions left for afterwards runs.
|
||||
/// their public profile with its avatar, their address's hash and the person
|
||||
/// with their other identities. Once the transaction holds, what the host's
|
||||
/// deletions left for afterwards runs.
|
||||
Future<void> deletePerson(Session session, UuidValue authUserId) async {
|
||||
final deletions = HalkyonBase.of(session.serverpod).deletePersonData;
|
||||
final afterwards = await session.db.transaction((transaction) async {
|
||||
|
|
@ -25,6 +27,19 @@ Future<void> deletePerson(Session session, UuidValue authUserId) async {
|
|||
afterwards.add(after);
|
||||
}
|
||||
}
|
||||
// Their public profile, and its avatar unless another profile shows the
|
||||
// same picture.
|
||||
// DELETE FROM "public_profile" WHERE "authUserId" = $1
|
||||
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);
|
||||
}
|
||||
}
|
||||
// Their address's hash: the way they signed in.
|
||||
// DELETE FROM "email_identity" WHERE "authUserId" = $1
|
||||
await EmailIdentity.db.deleteWhere(
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'dart:typed_data' as _idt;
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
|
||||
as _iacs;
|
||||
|
|
@ -22,6 +23,7 @@ import '../auth/email_sign_in_endpoint.dart' as _irqdog5c;
|
|||
import '../auth/github_sign_in_endpoint.dart' as _iasw79m5;
|
||||
import '../auth/github_upgrade_endpoint.dart' as _ivnugbf2;
|
||||
import '../instance/instance_endpoint.dart' as _i3g8bfc2;
|
||||
import '../profile/profile_endpoint.dart' as _i6ky944g;
|
||||
|
||||
class Endpoints extends _is.EndpointDispatch {
|
||||
@override
|
||||
|
|
@ -69,6 +71,18 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
'instance',
|
||||
'halkyon_base',
|
||||
),
|
||||
'profile': _i6ky944g.ProfileEndpoint()
|
||||
..initialize(
|
||||
server,
|
||||
'profile',
|
||||
'halkyon_base',
|
||||
),
|
||||
'profiles': _i6ky944g.ProfilesEndpoint()
|
||||
..initialize(
|
||||
server,
|
||||
'profiles',
|
||||
'halkyon_base',
|
||||
),
|
||||
};
|
||||
connectors['account'] = _is.EndpointConnector(
|
||||
name: 'account',
|
||||
|
|
@ -297,6 +311,152 @@ 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'],
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
modules['serverpod_auth_idp'] = _iais.Endpoints()
|
||||
..initializeEndpoints(server);
|
||||
modules['serverpod_auth_core'] = _iacs.Endpoints()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,203 @@
|
|||
/* 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_base_server/src/generated/protocol.dart' as _ibewtqgw;
|
||||
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: _ibewtqgw.Protocol().deserialize<List<String>>(
|
||||
jsonSerialization['links'],
|
||||
),
|
||||
verifiedLinks: jsonSerialization['verifiedLinks'] == null
|
||||
? null
|
||||
: _ibewtqgw.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__': 'halkyon_base.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__': 'halkyon_base.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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
/* 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;
|
||||
|
||||
/// The person asked for has no public profile here, or the caller has none
|
||||
/// to change.
|
||||
abstract class ProfileNotFoundException
|
||||
implements
|
||||
_is.SerializableException,
|
||||
_is.SerializableModel,
|
||||
_is.ProtocolSerialization {
|
||||
ProfileNotFoundException._({required this.what});
|
||||
|
||||
factory ProfileNotFoundException({required String what}) =
|
||||
_ProfileNotFoundExceptionImpl;
|
||||
|
||||
factory ProfileNotFoundException.fromJson(
|
||||
Map<String, dynamic> jsonSerialization,
|
||||
) {
|
||||
return ProfileNotFoundException(what: jsonSerialization['what'] as String);
|
||||
}
|
||||
|
||||
/// What was looked for: `profile`.
|
||||
String what;
|
||||
|
||||
/// Returns a shallow copy of this [ProfileNotFoundException]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
ProfileNotFoundException copyWith({String? what});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'halkyon_base.ProfileNotFoundException',
|
||||
'what': what,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'halkyon_base.ProfileNotFoundException',
|
||||
'what': what,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ProfileNotFoundException(what: $what)';
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileNotFoundExceptionImpl extends ProfileNotFoundException {
|
||||
_ProfileNotFoundExceptionImpl({required String what}) : super._(what: what);
|
||||
|
||||
/// Returns a shallow copy of this [ProfileNotFoundException]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
@override
|
||||
ProfileNotFoundException copyWith({String? what}) {
|
||||
return ProfileNotFoundException(what: what ?? this.what);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
/* 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||
/* To generate run: "serverpod generate" */
|
||||
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
// ignore_for_file: non_constant_identifier_names
|
||||
// ignore_for_file: public_member_api_docs
|
||||
// ignore_for_file: type_literal_in_constant_pattern
|
||||
// ignore_for_file: use_super_parameters
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
import '../profile/profile_refusal.dart' as _i5pl5k19;
|
||||
|
||||
/// Saving or removing a public profile was refused; nothing changed.
|
||||
abstract class ProfileRefusedException
|
||||
implements
|
||||
_is.SerializableException,
|
||||
_is.SerializableModel,
|
||||
_is.ProtocolSerialization {
|
||||
ProfileRefusedException._({required this.reason});
|
||||
|
||||
factory ProfileRefusedException({required _i5pl5k19.ProfileRefusal reason}) =
|
||||
_ProfileRefusedExceptionImpl;
|
||||
|
||||
factory ProfileRefusedException.fromJson(
|
||||
Map<String, dynamic> jsonSerialization,
|
||||
) {
|
||||
return ProfileRefusedException(
|
||||
reason: _i5pl5k19.ProfileRefusal.fromJson(
|
||||
(jsonSerialization['reason'] as String),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Why it was refused.
|
||||
_i5pl5k19.ProfileRefusal reason;
|
||||
|
||||
/// Returns a shallow copy of this [ProfileRefusedException]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
ProfileRefusedException copyWith({_i5pl5k19.ProfileRefusal? reason});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'halkyon_base.ProfileRefusedException',
|
||||
'reason': reason.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'halkyon_base.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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,845 @@
|
|||
/* 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_base_server/src/generated/protocol.dart' as _ibewtqgw;
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
|
||||
/// A person's public face (Halkyon Learn's ADR 0005, rule 2; ADR 0016): the
|
||||
/// handle others find them by on the instance's account domain
|
||||
/// (`raisondetredev@halkyon.eu`), a display name, a bio, links and an avatar,
|
||||
/// the same in every service of the instance. Only people with an identity
|
||||
/// have one; deleting the person deletes it. Never sent as it is: apps get 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: _ibewtqgw.Protocol().deserialize<List<String>>(
|
||||
jsonSerialization['links'],
|
||||
),
|
||||
verifiedLinks: jsonSerialization['verifiedLinks'] == null
|
||||
? null
|
||||
: _ibewtqgw.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__': 'halkyon_base.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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,11 @@ import 'auth/github_sign_in_refusal.dart' as _ir1gd9p5;
|
|||
import 'auth/github_sign_in_refused_exception.dart' as _i0xfcu5n;
|
||||
import 'auth/sign_in_ways.dart' as _ie6tbh85;
|
||||
import 'instance/instance_basics.dart' as _i8jixqx6;
|
||||
import 'profile/profile.dart' as _ia5j8fch;
|
||||
import 'profile/profile_not_found_exception.dart' as _iim3hfcf;
|
||||
import 'profile/profile_refusal.dart' as _i6j6efd1;
|
||||
import 'profile/profile_refused_exception.dart' as _idvc5e7p;
|
||||
import 'profile/public_profile.dart' as _ioppe2ep;
|
||||
export 'auth/email_code_accepted.dart';
|
||||
export 'auth/email_code_request.dart';
|
||||
export 'auth/email_identity.dart';
|
||||
|
|
@ -35,6 +40,11 @@ export 'auth/github_sign_in_refusal.dart';
|
|||
export 'auth/github_sign_in_refused_exception.dart';
|
||||
export 'auth/sign_in_ways.dart';
|
||||
export 'instance/instance_basics.dart';
|
||||
export 'profile/profile.dart';
|
||||
export 'profile/profile_not_found_exception.dart';
|
||||
export 'profile/profile_refusal.dart';
|
||||
export 'profile/profile_refused_exception.dart';
|
||||
export 'profile/public_profile.dart';
|
||||
|
||||
class Protocol extends _is.DatabaseSerializationManager {
|
||||
Protocol._();
|
||||
|
|
@ -186,6 +196,111 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
],
|
||||
managed: true,
|
||||
),
|
||||
_isp.TableDefinition(
|
||||
name: 'public_profile',
|
||||
dartName: 'PublicProfile',
|
||||
schema: 'public',
|
||||
module: 'halkyon_base',
|
||||
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,
|
||||
),
|
||||
..._iais.Protocol.targetTableDefinitions,
|
||||
..._iacs.Protocol.targetTableDefinitions,
|
||||
];
|
||||
|
|
@ -253,6 +368,21 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (t == _i8jixqx6.InstanceBasics) {
|
||||
return _i8jixqx6.InstanceBasics.fromJson(data) as T;
|
||||
}
|
||||
if (t == _ia5j8fch.Profile) {
|
||||
return _ia5j8fch.Profile.fromJson(data) as T;
|
||||
}
|
||||
if (t == _iim3hfcf.ProfileNotFoundException) {
|
||||
return _iim3hfcf.ProfileNotFoundException.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 == _is.getType<_iys46ui1.EmailCodeAccepted?>()) {
|
||||
return (data != null ? _iys46ui1.EmailCodeAccepted.fromJson(data) : null)
|
||||
as T;
|
||||
|
|
@ -294,6 +424,41 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
return (data != null ? _i8jixqx6.InstanceBasics.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<_iim3hfcf.ProfileNotFoundException?>()) {
|
||||
return (data != null
|
||||
? _iim3hfcf.ProfileNotFoundException.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 == List<String>) {
|
||||
return (data as List).map((e) => deserialize<String>(e)).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<String>) {
|
||||
return (data as List).map((e) => deserialize<String>(e)).toList() as T;
|
||||
}
|
||||
try {
|
||||
return _iais.Protocol().deserialize<T>(data, t);
|
||||
} on _is.DeserializationTypeNotFoundException catch (_) {}
|
||||
|
|
@ -317,6 +482,11 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
_i0xfcu5n.GitHubSignInRefusedException => 'GitHubSignInRefusedException',
|
||||
_ie6tbh85.SignInWays => 'SignInWays',
|
||||
_i8jixqx6.InstanceBasics => 'InstanceBasics',
|
||||
_ia5j8fch.Profile => 'Profile',
|
||||
_iim3hfcf.ProfileNotFoundException => 'ProfileNotFoundException',
|
||||
_i6j6efd1.ProfileRefusal => 'ProfileRefusal',
|
||||
_idvc5e7p.ProfileRefusedException => 'ProfileRefusedException',
|
||||
_ioppe2ep.PublicProfile => 'PublicProfile',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
|
@ -352,6 +522,16 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
return 'SignInWays';
|
||||
case _i8jixqx6.InstanceBasics():
|
||||
return 'InstanceBasics';
|
||||
case _ia5j8fch.Profile():
|
||||
return 'Profile';
|
||||
case _iim3hfcf.ProfileNotFoundException():
|
||||
return 'ProfileNotFoundException';
|
||||
case _i6j6efd1.ProfileRefusal():
|
||||
return 'ProfileRefusal';
|
||||
case _idvc5e7p.ProfileRefusedException():
|
||||
return 'ProfileRefusedException';
|
||||
case _ioppe2ep.PublicProfile():
|
||||
return 'PublicProfile';
|
||||
}
|
||||
className = _isp.Protocol().getClassNameForObject(data);
|
||||
if (className != null) {
|
||||
|
|
@ -393,6 +573,21 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (dataClassName == 'InstanceBasics') {
|
||||
return deserialize<_i8jixqx6.InstanceBasics>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'Profile') {
|
||||
return deserialize<_ia5j8fch.Profile>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'ProfileNotFoundException') {
|
||||
return deserialize<_iim3hfcf.ProfileNotFoundException>(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.startsWith('serverpod.')) {
|
||||
data['className'] = dataClassName.substring(10);
|
||||
return _isp.Protocol().deserializeByClassName(data);
|
||||
|
|
@ -484,6 +679,8 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
return _iqxum1j8.EmailCodeRequest.t;
|
||||
case _id1dpd4g.EmailIdentity:
|
||||
return _id1dpd4g.EmailIdentity.t;
|
||||
case _ioppe2ep.PublicProfile:
|
||||
return _ioppe2ep.PublicProfile.t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,3 +14,13 @@ gitHubUpgrade:
|
|||
- upgrade:
|
||||
instance:
|
||||
- basics:
|
||||
profile:
|
||||
- mine:
|
||||
- save:
|
||||
- checkLinks:
|
||||
- checkHandle:
|
||||
- setAvatar:
|
||||
- removeAvatar:
|
||||
- remove:
|
||||
profiles:
|
||||
- byHandle:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ typedef PersonDataDeletion = Future<AfterDeletion?> Function(
|
|||
/// What a [PersonDataDeletion] does once the deletion is committed.
|
||||
typedef AfterDeletion = Future<void> Function();
|
||||
|
||||
/// What a person's profile page says of the app at its foot (`english`,
|
||||
/// `german`), and where it links (`link`): whole sentences, since a language
|
||||
/// may want another case of the app's description ("Auf Halkyon Learn, einer
|
||||
/// Lern-App …").
|
||||
typedef ProfilePageNote = ({String english, String german, Uri link});
|
||||
|
||||
/// How a host, the server of a Halkyon app, sets halkyon_base up: once, on its
|
||||
/// pod, before the pod starts ([configure]).
|
||||
final class HalkyonBase {
|
||||
|
|
@ -25,6 +31,7 @@ final class HalkyonBase {
|
|||
required this.upstreamSourceCodeUrl,
|
||||
required this.minimumAppBuild,
|
||||
this.deletePersonData = const [],
|
||||
this.profilePageNote,
|
||||
});
|
||||
|
||||
/// Which app this server serves, as its apps know it (`learn` for Halkyon
|
||||
|
|
@ -50,6 +57,10 @@ final class HalkyonBase {
|
|||
/// [PersonDataDeletion]); pruning goes through the same deletion.
|
||||
final List<PersonDataDeletion> deletePersonData;
|
||||
|
||||
/// What people's profile pages say of the app at their foot; null for
|
||||
/// nothing.
|
||||
final ProfilePageNote? profilePageNote;
|
||||
|
||||
/// Sets [pod] up with [base]. Call it before the pod starts; calling it
|
||||
/// again replaces the configuration (tests do).
|
||||
static void configure(Serverpod pod, HalkyonBase base) => _ofPod[pod] = base;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,290 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_base_server/src/halkyon_base.dart';
|
||||
import 'package:html/parser.dart' as html;
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
||||
// A profile's links are verified as Mastodon verifies them (Halkyon Learn's
|
||||
// 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:
|
||||
'${HalkyonBase.of(pod).appName} '
|
||||
'(+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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
### 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
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_base_server/src/auth/scopes.dart';
|
||||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_base_server/src/register/account_register.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
||||
/// Your public profile (Halkyon Learn's ADR 0005, rule 2; ADR 0016): the
|
||||
/// handle others find you by, your display name, bio and links, the same in
|
||||
/// every service of the instance. 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) =>
|
||||
_register(session).profileOf(session, _caller(session));
|
||||
|
||||
/// 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 yours is fixed.
|
||||
Future<Profile> save(
|
||||
Session session,
|
||||
String handle,
|
||||
String displayName,
|
||||
String bio,
|
||||
List<String> links,
|
||||
) => _register(session).saveProfile(
|
||||
session,
|
||||
_caller(session),
|
||||
handle: handle,
|
||||
displayName: displayName,
|
||||
bio: bio,
|
||||
links: links,
|
||||
);
|
||||
|
||||
/// Checks which of your links' pages link back to your profile's page
|
||||
/// with `rel="me"` (Halkyon Learn's 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 [ProfileNotFoundException]
|
||||
/// while you have no profile.
|
||||
Future<Profile> checkLinks(Session session) =>
|
||||
_register(session).checkLinks(session, _caller(session));
|
||||
|
||||
/// 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 yours is fixed; null when it can, as your own can. For
|
||||
/// the app to say so while it is typed; 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) =>
|
||||
_register(session).checkHandle(session, _caller(session), handle);
|
||||
|
||||
/// 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
|
||||
/// [ProfileNotFoundException] while you have no profile.
|
||||
Future<Profile> setAvatar(Session session, ByteData png) =>
|
||||
_register(session).setAvatar(session, _caller(session), png);
|
||||
|
||||
/// Shows no avatar any more. Throws [ProfileNotFoundException] while you
|
||||
/// have no profile.
|
||||
Future<Profile> removeAvatar(Session session) =>
|
||||
_register(session).removeAvatar(session, _caller(session));
|
||||
|
||||
/// Removes your public profile, freeing its handle; nothing when you have
|
||||
/// none. Throws [ProfileRefusedException] once your handle is fixed:
|
||||
/// others refer to it.
|
||||
Future<void> remove(Session session) =>
|
||||
_register(session).removeProfile(session, _caller(session));
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// [ProfileNotFoundException] when there is none.
|
||||
Future<Profile> byHandle(Session session, String handle) async =>
|
||||
await _register(session).profileByHandle(session, handle) ??
|
||||
(throw ProfileNotFoundException(what: 'profile'));
|
||||
}
|
||||
|
||||
AccountRegister _register(Session session) =>
|
||||
AccountRegister.of(session.serverpod);
|
||||
|
||||
UuidValue _caller(Session session) => session.authenticated!.authUserId;
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
### The person asked for has no public profile here, or the caller has none
|
||||
### to change.
|
||||
exception: ProfileNotFoundException
|
||||
fields:
|
||||
### What was looked for: `profile`.
|
||||
what: String
|
||||
227
packages/halkyon_base_server/lib/src/profile/profile_page.dart
Normal file
227
packages/halkyon_base_server/lib/src/profile/profile_page.dart
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_base_server/src/halkyon_base.dart';
|
||||
import 'package:halkyon_base_server/src/profile/profile_rules.dart';
|
||||
import 'package:halkyon_base_server/src/register/account_register.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
||||
// A person's profile on the web (Halkyon Learn's ADR 0011, decided 2026-10-15;
|
||||
// ADR 0016): 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;
|
||||
final profile = handle == null || !isHandle(handle)
|
||||
? null
|
||||
: await AccountRegister.of(
|
||||
session.serverpod,
|
||||
).profileByHandle(session, handle);
|
||||
if (profile == null) {
|
||||
return (
|
||||
status: 404,
|
||||
html: _notFound(german: german),
|
||||
location: null,
|
||||
);
|
||||
}
|
||||
final own = profilePageUrl(session.serverpod, profile.handle);
|
||||
if (host.split(':').first.toLowerCase() != own.host.toLowerCase()) {
|
||||
return (status: 308, html: '', location: '$own');
|
||||
}
|
||||
return (
|
||||
status: 200,
|
||||
html: _page(
|
||||
profile,
|
||||
german: german,
|
||||
note: HalkyonBase.of(session.serverpod).profilePageNote,
|
||||
),
|
||||
location: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Serves profile pages on [pod]'s web server, at `/@handle`: one path
|
||||
/// segment, which a host's literal routes come before (Halkyon Learn: its
|
||||
/// assistant connector's). Caddy forwards `/@…` to it (the host's
|
||||
/// `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,
|
||||
required ProfilePageNote? note,
|
||||
}) {
|
||||
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>');
|
||||
}
|
||||
if (note != null) {
|
||||
page.writeln(
|
||||
'<p class="app">${_text.convert(german ? note.german : note.english)} '
|
||||
'<a href="${_attribute.convert('${note.link}')}">'
|
||||
'${_text.convert(_shown('${note.link}'))}</a></p>',
|
||||
);
|
||||
}
|
||||
page
|
||||
..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}';
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
### 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
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
### Saving or removing a public profile was refused; nothing changed.
|
||||
exception: ProfileRefusedException
|
||||
fields:
|
||||
### Why it was refused.
|
||||
reason: ProfileRefusal
|
||||
188
packages/halkyon_base_server/lib/src/profile/profile_rules.dart
Normal file
188
packages/halkyon_base_server/lib/src/profile/profile_rules.dart
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/protocol.dart' show CloudStorageEntry;
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
||||
/// Handles kept for the instance itself, whatever their case: names people
|
||||
/// would take for the instance's own, and paths the instance may need.
|
||||
const reservedHandles = {
|
||||
'abuse',
|
||||
'admin',
|
||||
'administrator',
|
||||
'api',
|
||||
'halkyon',
|
||||
'help',
|
||||
'info',
|
||||
'instance',
|
||||
'moderator',
|
||||
'postmaster',
|
||||
'root',
|
||||
'security',
|
||||
'settings',
|
||||
'support',
|
||||
'system',
|
||||
'webmaster',
|
||||
};
|
||||
|
||||
/// The longest display name.
|
||||
const maxDisplayNameLength = 30;
|
||||
|
||||
/// The longest bio.
|
||||
const maxBioLength = 500;
|
||||
|
||||
/// The most links a profile holds.
|
||||
const maxLinks = 4;
|
||||
|
||||
/// The longest link.
|
||||
const maxLinkLength = 2000;
|
||||
|
||||
/// The largest avatar, in bytes.
|
||||
const maxAvatarBytes = 1024 * 1024;
|
||||
|
||||
/// Where avatars are kept: Serverpod's public storage, served to anyone.
|
||||
const avatarStorage = 'public';
|
||||
|
||||
/// The path of the avatar whose SHA-256 is [hash], in [avatarStorage].
|
||||
String avatarPath(String hash) => 'avatars/$hash.png';
|
||||
|
||||
/// Whether [bytes] are a PNG of at most [maxAvatarBytes]: by its signature;
|
||||
/// the app sends one it drew itself, without the picture's metadata.
|
||||
bool isAvatar(ByteData bytes) {
|
||||
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
if (bytes.lengthInBytes > maxAvatarBytes ||
|
||||
bytes.lengthInBytes <= signature.length) {
|
||||
return false;
|
||||
}
|
||||
for (final (i, byte) in signature.indexed) {
|
||||
if (bytes.getUint8(i) != byte) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
final _handle = RegExp(r'^[A-Za-z0-9_]{1,30}$');
|
||||
|
||||
/// Whether [handle] has a handle's form: 1 to 30 letters a to z, digits or
|
||||
/// underscores.
|
||||
bool isHandle(String handle) => _handle.hasMatch(handle);
|
||||
|
||||
/// Why a profile of [handle], [displayName], [bio] and [links] can't be
|
||||
/// saved, judged by itself (not whether its handle is free); null when it
|
||||
/// can. Handles as Mastodon's: 1 to 30 letters a to z, digits or
|
||||
/// underscores, so they read the same everywhere in the fediverse.
|
||||
ProfileRefusal? profileRefusal({
|
||||
required String handle,
|
||||
required String displayName,
|
||||
required String bio,
|
||||
required List<String> links,
|
||||
}) {
|
||||
if (!isHandle(handle)) {
|
||||
return ProfileRefusal.handleInvalid;
|
||||
}
|
||||
if (reservedHandles.contains(handle.toLowerCase())) {
|
||||
return ProfileRefusal.handleReserved;
|
||||
}
|
||||
if (displayName.runes.length > maxDisplayNameLength) {
|
||||
return ProfileRefusal.displayNameTooLong;
|
||||
}
|
||||
if (bio.runes.length > maxBioLength) {
|
||||
return ProfileRefusal.bioTooLong;
|
||||
}
|
||||
if (links.length > maxLinks) {
|
||||
return ProfileRefusal.tooManyLinks;
|
||||
}
|
||||
for (final link in links) {
|
||||
final uri = Uri.tryParse(link);
|
||||
if (link.length > maxLinkLength ||
|
||||
uri == null ||
|
||||
!(uri.isScheme('https') || uri.isScheme('http')) ||
|
||||
uri.host.isEmpty) {
|
||||
return ProfileRefusal.linkInvalid;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The domain people's handles are on (Halkyon Learn's ADR 0005, rule 2):
|
||||
/// the operator's `ACCOUNT_DOMAIN` (`halkyon.eu`), else the server's own
|
||||
/// public host. It can never change once anyone follows a handle on it.
|
||||
String accountDomain(Serverpod pod) =>
|
||||
operatorAccountDomain() ?? pod.config.apiServer.publicHost;
|
||||
|
||||
/// The account domain the operator set (`ACCOUNT_DOMAIN`); null for none.
|
||||
String? operatorAccountDomain() =>
|
||||
switch (Platform.environment['ACCOUNT_DOMAIN']?.trim()) {
|
||||
final domain? when domain.isNotEmpty => domain,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// The address of [handle]'s profile page: on the operator's account domain
|
||||
/// over HTTPS where one is set (`ACCOUNT_DOMAIN`); else on the instance's web
|
||||
/// server, as it is reached.
|
||||
Uri profilePageUrl(Serverpod pod, String handle) {
|
||||
if (operatorAccountDomain() case final domain?) {
|
||||
return Uri.https(domain, '/@$handle');
|
||||
}
|
||||
final web = pod.config.webServer;
|
||||
if (web == null) {
|
||||
return Uri.https(pod.config.apiServer.publicHost, '/@$handle');
|
||||
}
|
||||
final usual = web.publicScheme == 'https' ? 443 : 80;
|
||||
return Uri(
|
||||
scheme: web.publicScheme,
|
||||
host: web.publicHost,
|
||||
port: web.publicPort == usual ? null : web.publicPort,
|
||||
path: '/@$handle',
|
||||
);
|
||||
}
|
||||
|
||||
/// [row] as the app sees it: without whose it is.
|
||||
Future<Profile> profileViewOf(Session session, PublicProfile row) async =>
|
||||
Profile(
|
||||
handle: row.handle,
|
||||
address: '${row.handle}@${accountDomain(session.serverpod)}',
|
||||
displayName: row.displayName,
|
||||
bio: row.bio,
|
||||
links: row.links,
|
||||
verifiedLinks: [
|
||||
for (final link in row.verifiedLinks ?? const <String>[])
|
||||
if (row.links.contains(link)) link,
|
||||
],
|
||||
pageUrl: '${profilePageUrl(session.serverpod, row.handle)}',
|
||||
avatarUrl: switch (row.avatarHash) {
|
||||
final hash? => (await session.storage.publicDownloadUrl(
|
||||
storageId: avatarStorage,
|
||||
path: avatarPath(hash),
|
||||
)).toString(),
|
||||
null => null,
|
||||
},
|
||||
handleFixed: row.firstPublishedAt != null,
|
||||
);
|
||||
|
||||
/// Deletes the avatar whose SHA-256 is [hash] unless a profile still shows
|
||||
/// it: two people may have chosen the same picture.
|
||||
Future<void> forgetAvatar(
|
||||
Session session,
|
||||
String hash, {
|
||||
Transaction? transaction,
|
||||
}) async {
|
||||
// SELECT count(*) FROM "public_profile" WHERE "avatarHash" = $1
|
||||
final shown = await PublicProfile.db.count(
|
||||
session,
|
||||
where: (t) => t.avatarHash.equals(hash),
|
||||
transaction: transaction,
|
||||
);
|
||||
if (shown == 0) {
|
||||
// DELETE FROM "serverpod_cloud_storage"
|
||||
// WHERE "storageId" = 'public' AND "path" = 'avatars/<hash>.png'
|
||||
await CloudStorageEntry.db.deleteWhere(
|
||||
session,
|
||||
where: (t) =>
|
||||
t.storageId.equals(avatarStorage) & t.path.equals(avatarPath(hash)),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue