mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Co-authored-by: Jitter <d14dfe033ef0f809866f9f984de04821b0d900d7652fd85a54776ee40ca3a68f@buzz.block.builderlab.xyz> Signed-off-by: Matt Toohey <contact@matttoohey.com>
149 lines
4.4 KiB
Dart
149 lines
4.4 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:http/http.dart' as http;
|
|
|
|
import 'builderlab_account.dart';
|
|
import 'bounded_http_response.dart';
|
|
|
|
/// Public policy text bound to the exact version the user accepts.
|
|
class HostedJoinPolicy {
|
|
const HostedJoinPolicy({
|
|
required this.version,
|
|
required this.ageRequired,
|
|
this.terms,
|
|
this.privacy,
|
|
});
|
|
final String version;
|
|
final bool ageRequired;
|
|
final String? terms;
|
|
final String? privacy;
|
|
}
|
|
|
|
enum HostedInviteFailure { policyRequired, expired, exhausted, invalid }
|
|
|
|
/// Reads only a bounded JSON document, never follows redirects or logs bodies.
|
|
Future<Map<String, dynamic>> readJoinResponse(
|
|
http.Client client,
|
|
http.Request request, {
|
|
bool allowNotFound = false,
|
|
}) async {
|
|
final http.Response response;
|
|
final Map<String, dynamic> data;
|
|
try {
|
|
response = await boundedHttpResponse(client, request);
|
|
if (allowNotFound && response.statusCode == 404) return {};
|
|
if (response.statusCode == 429 ||
|
|
(response.statusCode >= 500 && response.statusCode < 600)) {
|
|
throw AccountFailure.unavailable;
|
|
}
|
|
data = jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>;
|
|
} on FormatException {
|
|
throw AccountFailure.invalidResponse;
|
|
} on TypeError {
|
|
throw AccountFailure.invalidResponse;
|
|
}
|
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
throw switch (data['error']) {
|
|
'join_policy_required' ||
|
|
'join_policy_not_accepted' => HostedInviteFailure.policyRequired,
|
|
'invite_expired' => HostedInviteFailure.expired,
|
|
'invite_exhausted' => HostedInviteFailure.exhausted,
|
|
'invite_invalid' => HostedInviteFailure.invalid,
|
|
_ => AccountFailure.denied,
|
|
};
|
|
}
|
|
return data;
|
|
}
|
|
|
|
Future<String?> freshJoinPolicyReceipt({
|
|
required http.Client client,
|
|
required Uri origin,
|
|
required String code,
|
|
required Future<bool> Function(HostedJoinPolicy) confirm,
|
|
required void Function() checkCurrent,
|
|
bool allowLegacyRelay = false,
|
|
}) async {
|
|
Future<Map<String, dynamic>> send(
|
|
String method,
|
|
String path, [
|
|
Map<String, dynamic>? body,
|
|
]) async {
|
|
checkCurrent();
|
|
final request = http.Request(
|
|
method,
|
|
origin.replace(
|
|
scheme: origin.scheme == 'ws' || origin.scheme == 'http'
|
|
? 'http'
|
|
: 'https',
|
|
path: path,
|
|
),
|
|
)..followRedirects = false;
|
|
if (body != null) {
|
|
request.headers['Content-Type'] = 'application/json';
|
|
request.body = jsonEncode(body);
|
|
}
|
|
final data = await readJoinResponse(client, request);
|
|
checkCurrent();
|
|
return data;
|
|
}
|
|
|
|
checkCurrent();
|
|
final policy = await loadJoinPolicy(
|
|
client: client,
|
|
origin: origin,
|
|
allowLegacyRelay: allowLegacyRelay,
|
|
);
|
|
checkCurrent();
|
|
if (policy == null) return null;
|
|
if (!await confirm(policy)) throw HostedInviteFailure.policyRequired;
|
|
checkCurrent();
|
|
final accepted = await send('POST', '/api/invites/accept-policy', {
|
|
'code': code,
|
|
'policy_version': policy.version,
|
|
'age_confirmed': policy.ageRequired,
|
|
});
|
|
final receipt = accepted['receipt'];
|
|
if (receipt is! String || receipt.isEmpty || receipt.length > 8192) {
|
|
throw AccountFailure.invalidResponse;
|
|
}
|
|
return receipt;
|
|
}
|
|
|
|
/// Fetch a policy without accepting it; legacy self-hosted relays may omit it.
|
|
Future<HostedJoinPolicy?> loadJoinPolicy({
|
|
required http.Client client,
|
|
required Uri origin,
|
|
bool allowLegacyRelay = false,
|
|
}) async {
|
|
final request = http.Request(
|
|
'GET',
|
|
origin.replace(
|
|
scheme: origin.scheme == 'ws' || origin.scheme == 'http'
|
|
? 'http'
|
|
: 'https',
|
|
path: '/api/join-policy',
|
|
),
|
|
)..followRedirects = false;
|
|
final data = await readJoinResponse(
|
|
client,
|
|
request,
|
|
allowNotFound: allowLegacyRelay,
|
|
);
|
|
final raw = data['policy'];
|
|
if (raw == null) return null;
|
|
if (raw is! Map ||
|
|
raw['version'] is! String ||
|
|
(raw['version'] as String).isEmpty ||
|
|
raw['age_attestation_required'] is! bool ||
|
|
(raw['terms_markdown'] != null && raw['terms_markdown'] is! String) ||
|
|
(raw['privacy_markdown'] != null && raw['privacy_markdown'] is! String)) {
|
|
throw AccountFailure.invalidResponse;
|
|
}
|
|
return HostedJoinPolicy(
|
|
version: raw['version'] as String,
|
|
ageRequired: raw['age_attestation_required'] as bool,
|
|
terms: raw['terms_markdown'] as String?,
|
|
privacy: raw['privacy_markdown'] as String?,
|
|
);
|
|
}
|