644 lines
22 KiB
Dart
644 lines
22 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:flutter_background_service/flutter_background_service.dart';
|
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|
|
|
import 'ctaphid_channel.dart';
|
|
import 'enrollment_db.dart';
|
|
import 'filter_proxy.dart';
|
|
import 'fido2_ops.dart';
|
|
import 'portal_html.dart';
|
|
import 'session_manager.dart';
|
|
|
|
const int kProxyPort = 8771;
|
|
const String kNotificationChannelId = 'kphone_proxy';
|
|
const String kNotificationChannelName = 'k_phone proxy service';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Top-level entry points — required by flutter_background_service isolate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
@pragma('vm:entry-point')
|
|
Future<bool> onIosBackground(ServiceInstance service) async => true;
|
|
|
|
@pragma('vm:entry-point')
|
|
void onServiceStart(ServiceInstance service) async {
|
|
final proxy = _ProxyServer(service);
|
|
service.on('stop').listen((_) async {
|
|
await proxy.stop();
|
|
service.stopSelf();
|
|
});
|
|
await proxy.start();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Service bootstrap (called from main())
|
|
// ---------------------------------------------------------------------------
|
|
|
|
@pragma('vm:entry-point')
|
|
class ProxyService {
|
|
static Future<void> initialize() async {
|
|
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
|
|
const channel = AndroidNotificationChannel(
|
|
kNotificationChannelId,
|
|
kNotificationChannelName,
|
|
description: 'Shows when the ChromeCard proxy is running',
|
|
importance: Importance.low,
|
|
);
|
|
await flutterLocalNotificationsPlugin
|
|
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
|
?.createNotificationChannel(channel);
|
|
|
|
final service = FlutterBackgroundService();
|
|
await service.configure(
|
|
androidConfiguration: AndroidConfiguration(
|
|
onStart: onServiceStart,
|
|
autoStart: true,
|
|
isForegroundMode: true,
|
|
notificationChannelId: kNotificationChannelId,
|
|
initialNotificationTitle: 'k_phone proxy',
|
|
initialNotificationContent: 'Starting…',
|
|
foregroundServiceNotificationId: 1,
|
|
),
|
|
iosConfiguration: IosConfiguration(
|
|
autoStart: false,
|
|
onForeground: onServiceStart,
|
|
onBackground: onIosBackground,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Proxy server (runs inside the background service isolate)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class _ProxyServer {
|
|
final ServiceInstance _service;
|
|
HttpServer? _server;
|
|
final FilterProxy _filterProxy = FilterProxy();
|
|
final SessionManager _sessions = SessionManager();
|
|
final EnrollmentDb _db = EnrollmentDb();
|
|
int? _cardCid;
|
|
bool _cardAttached = false;
|
|
bool _running = false;
|
|
|
|
_ProxyServer(this._service);
|
|
|
|
void _emit(String msg) {
|
|
_service.invoke('status', {
|
|
'running': _running,
|
|
'cardAttached': _cardAttached,
|
|
'message': msg,
|
|
'log': '[${DateTime.now().toIso8601String()}] $msg',
|
|
});
|
|
}
|
|
|
|
Future<void> start() async {
|
|
_running = true;
|
|
_emit('Starting proxy on :$kProxyPort');
|
|
|
|
_filterProxy.onLog = _emit;
|
|
try {
|
|
await _filterProxy.seedDefaultGatedHosts();
|
|
await _filterProxy.loadGatedHosts();
|
|
await _filterProxy.start();
|
|
} catch (e) {
|
|
_emit('Filter proxy failed to start: $e');
|
|
}
|
|
|
|
// Card detection and DB loading are independent — run in parallel.
|
|
await Future.wait([_tryOpenCard(), _db.ensureLoaded()]);
|
|
|
|
_emit('No TLS certs found — running plain HTTP (dev mode)');
|
|
try {
|
|
_server = await HttpServer.bind(InternetAddress.anyIPv4, kProxyPort);
|
|
_emit('Listening on :$kProxyPort');
|
|
_server!.listen(_handleRequest, onError: (e) => _emit('Server error: $e'));
|
|
} catch (e) {
|
|
_emit('FATAL: Could not bind :$kProxyPort — $e');
|
|
_running = false;
|
|
}
|
|
}
|
|
|
|
Future<void> stop() async {
|
|
_running = false;
|
|
try {
|
|
await _filterProxy.stop();
|
|
await _server?.close(force: true);
|
|
await closeCard();
|
|
} finally {
|
|
_emit('Stopped');
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Request dispatch
|
|
// -------------------------------------------------------------------------
|
|
|
|
Future<void> _handleRequest(HttpRequest req) async {
|
|
final path = req.uri.path;
|
|
_emit('${req.method} $path');
|
|
|
|
try {
|
|
if (req.method == 'GET') {
|
|
switch (path) {
|
|
case '/':
|
|
await _serveHtmlBytes(req, kPortalHtmlBytes);
|
|
case '/enroll':
|
|
await _serveHtmlBytes(req, kEnrollHtmlBytes);
|
|
case '/health':
|
|
await _handleHealth(req);
|
|
case '/enroll/list':
|
|
await _handleEnrollList(req);
|
|
default:
|
|
if (path.startsWith('/enroll/status')) {
|
|
await _handleEnrollStatus(req);
|
|
} else {
|
|
await _send(req.response, 404, {'ok': false, 'error': 'not found'});
|
|
}
|
|
}
|
|
} else if (req.method == 'POST') {
|
|
switch (path) {
|
|
case '/enroll/register':
|
|
await _handleEnrollRegister(req);
|
|
case '/enroll/update':
|
|
await _handleEnrollUpdate(req);
|
|
case '/enroll/delete':
|
|
await _handleEnrollDelete(req);
|
|
case '/session/login':
|
|
await _handleSessionLogin(req);
|
|
case '/session/status':
|
|
await _handleSessionStatus(req);
|
|
case '/session/logout':
|
|
await _handleSessionLogout(req);
|
|
case '/auth/get-token':
|
|
await _handleAuthGetToken(req);
|
|
default:
|
|
await _send(req.response, 404, {'ok': false, 'error': 'not found'});
|
|
}
|
|
} else if (req.method == 'CONNECT') {
|
|
await _handleConnect(req);
|
|
} else {
|
|
await _send(req.response, 405, {'ok': false, 'error': 'method not allowed'});
|
|
}
|
|
} catch (e) {
|
|
_emit('Error handling $path: $e');
|
|
try {
|
|
await _send(req.response, 500, {'ok': false, 'error': 'internal error'});
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Enrollment endpoints
|
|
// -------------------------------------------------------------------------
|
|
|
|
Future<void> _handleEnrollRegister(HttpRequest req) async {
|
|
final body = await _readJson(req);
|
|
if (body == null) return;
|
|
|
|
final r = await _parseUsernameAndDisplay(req, body);
|
|
if (r == null) return;
|
|
final (canonical, pretty) = r;
|
|
|
|
MakeCredentialResult? credential;
|
|
if (_cardAttached && _cardCid != null) {
|
|
try {
|
|
credential = await makeCredential(_cardCid!, canonical, displayName: pretty);
|
|
} catch (e) {
|
|
await _send(req.response, 401, {'ok': false, 'error': 'card registration failed: $e'});
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
final enrollment = await _db.register(
|
|
username: canonical,
|
|
displayName: pretty,
|
|
userIdB64: credential?.userIdB64,
|
|
credentialDataB64: credential?.credentialDataB64,
|
|
);
|
|
final users = await _db.list();
|
|
await _send(req.response, 200, {
|
|
..._enrollmentPayload(enrollment, created: true),
|
|
'users': users.map(_userSummary).toList(),
|
|
});
|
|
} on StateError {
|
|
await _send(req.response, 409, {'ok': false, 'error': 'user already enrolled'});
|
|
}
|
|
}
|
|
|
|
Future<void> _handleEnrollUpdate(HttpRequest req) async {
|
|
final body = await _readJson(req);
|
|
if (body == null) return;
|
|
|
|
final r = await _parseUsernameAndDisplay(req, body);
|
|
if (r == null) return;
|
|
final (canonical, pretty) = r;
|
|
|
|
try {
|
|
final enrollment = await _db.update(username: canonical, displayName: pretty);
|
|
await _send(req.response, 200, _enrollmentPayload(enrollment));
|
|
} on StateError {
|
|
await _send(req.response, 404, {'ok': false, 'error': 'user not enrolled'});
|
|
}
|
|
}
|
|
|
|
Future<void> _handleEnrollDelete(HttpRequest req) async {
|
|
final body = await _readJson(req);
|
|
if (body == null) return;
|
|
|
|
final canonical = await _parseUsername(req, body);
|
|
if (canonical == null) return;
|
|
|
|
try {
|
|
final enrollment = await _db.delete(canonical);
|
|
_sessions.revokeAll(canonical);
|
|
final users = await _db.list();
|
|
await _send(req.response, 200, {
|
|
'ok': true,
|
|
'username': enrollment.username,
|
|
'deleted': true,
|
|
'users': users.map(_userSummary).toList(),
|
|
});
|
|
} on StateError {
|
|
await _send(req.response, 404, {'ok': false, 'error': 'user not enrolled'});
|
|
}
|
|
}
|
|
|
|
Future<void> _handleEnrollStatus(HttpRequest req) async {
|
|
final username = req.uri.queryParameters['username'] ?? '';
|
|
if (username.isEmpty) {
|
|
await _send(req.response, 400, {'ok': false, 'error': 'username query required'});
|
|
return;
|
|
}
|
|
final enrollment = await _db.get(username);
|
|
if (enrollment == null) {
|
|
await _send(req.response, 404, {'ok': false, 'error': 'user not enrolled', 'username': username});
|
|
return;
|
|
}
|
|
await _send(req.response, 200, _enrollmentPayload(enrollment));
|
|
}
|
|
|
|
Future<void> _handleEnrollList(HttpRequest req) async {
|
|
final users = await _db.list();
|
|
await _send(req.response, 200, {
|
|
'ok': true,
|
|
'users': users.map(_userSummary).toList(),
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Session endpoints
|
|
// -------------------------------------------------------------------------
|
|
|
|
Future<void> _handleSessionLogin(HttpRequest req) async {
|
|
final body = await _readJson(req);
|
|
if (body == null) return;
|
|
|
|
final canonical = await _parseUsername(req, body);
|
|
if (canonical == null) return;
|
|
|
|
final enrollment = await _db.get(canonical);
|
|
if (enrollment == null) {
|
|
await _send(req.response, 403, {'ok': false, 'error': 'user not enrolled', 'username': canonical});
|
|
return;
|
|
}
|
|
|
|
if (enrollment.hasCredential && _cardCid != null) {
|
|
// FIDO2-direct: getAssertion + local verify.
|
|
// Random challenge is intentional here: session login only proves the
|
|
// user CAN authenticate (user-presence check). The resulting session token
|
|
// is for portal access. Per-request resource binding (challenge = SHA256
|
|
// of url|method|nonce) happens in _handleAuthGetToken, not here.
|
|
GetAssertionResult assertionResult;
|
|
try {
|
|
assertionResult = await getAssertion(_cardCid!, enrollment.credentialDataB64!);
|
|
} catch (e) {
|
|
await _send(req.response, 401, {'ok': false, 'error': 'card auth failed', 'details': e.toString()});
|
|
return;
|
|
}
|
|
final ok = verifyAssertion(
|
|
enrollment.credentialDataB64!,
|
|
assertionResult.authData,
|
|
assertionResult.signature,
|
|
assertionResult.clientDataHash,
|
|
);
|
|
if (!ok) {
|
|
await _send(req.response, 401, {'ok': false, 'error': 'card auth failed', 'details': 'signature verification failed'});
|
|
return;
|
|
}
|
|
} else if (!_cardAttached) {
|
|
await _send(req.response, 401, {'ok': false, 'error': 'card auth failed', 'details': 'no card attached'});
|
|
return;
|
|
}
|
|
// else: probe-mode enrollment (no FIDO2 credential stored) and card is
|
|
// physically attached — card presence is the only factor, accept the login.
|
|
|
|
final token = _sessions.issue(canonical);
|
|
final session = _sessions.getSession(token)!;
|
|
final expiresAt = session.expires.millisecondsSinceEpoch ~/ 1000;
|
|
final authMode = enrollment.hasCredential ? 'fido2_assertion' : 'card_presence_probe';
|
|
|
|
await _send(req.response, 200, {
|
|
'ok': true,
|
|
'username': canonical,
|
|
'session_token': token,
|
|
'expires_at': expiresAt,
|
|
'ttl_seconds': SessionManager.ttlSeconds,
|
|
'auth_mode': authMode,
|
|
});
|
|
}
|
|
|
|
Future<void> _handleSessionStatus(HttpRequest req) async {
|
|
await _drainBody(req);
|
|
final token = _extractBearerToken(req);
|
|
if (token == null) {
|
|
await _send(req.response, 401, {'ok': false, 'error': 'missing bearer token'});
|
|
return;
|
|
}
|
|
final session = _sessions.getSession(token);
|
|
if (session == null) {
|
|
await _send(req.response, 401, {'ok': false, 'error': 'invalid or expired session'});
|
|
return;
|
|
}
|
|
final expiresAt = session.expires.millisecondsSinceEpoch ~/ 1000;
|
|
final secondsRemaining = session.expires.difference(DateTime.now()).inSeconds.clamp(0, 99999);
|
|
await _send(req.response, 200, {
|
|
'ok': true,
|
|
'username': session.username,
|
|
'expires_at': expiresAt,
|
|
'seconds_remaining': secondsRemaining,
|
|
});
|
|
}
|
|
|
|
Future<void> _handleSessionLogout(HttpRequest req) async {
|
|
await _drainBody(req);
|
|
final token = _extractBearerToken(req);
|
|
if (token == null) {
|
|
await _send(req.response, 401, {'ok': false, 'error': 'missing bearer token'});
|
|
return;
|
|
}
|
|
final wasValid = _sessions.isValid(token);
|
|
_sessions.revoke(token);
|
|
await _send(req.response, 200, {'ok': true, 'invalidated': wasValid});
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// CONNECT tunnel (gated HTTPS)
|
|
// -------------------------------------------------------------------------
|
|
|
|
Future<void> _handleConnect(HttpRequest req) async {
|
|
if (!_sessions.hasAnyActiveSession()) {
|
|
await _send(req.response, 407, {'ok': false, 'error': 'authentication required'});
|
|
return;
|
|
}
|
|
|
|
// Extract host:port from the Host header (CONNECT target is "host:port")
|
|
final hostHeader = req.headers.value('host') ?? '';
|
|
final lastColon = hostHeader.lastIndexOf(':');
|
|
String connectHost;
|
|
int connectPort;
|
|
if (lastColon > 0) {
|
|
connectHost = hostHeader.substring(0, lastColon);
|
|
connectPort = int.tryParse(hostHeader.substring(lastColon + 1)) ?? 443;
|
|
} else {
|
|
connectHost = hostHeader;
|
|
connectPort = 443;
|
|
}
|
|
|
|
if (connectHost.isEmpty) {
|
|
await _send(req.response, 400, {'ok': false, 'error': 'missing CONNECT target'});
|
|
return;
|
|
}
|
|
|
|
Socket upstream;
|
|
try {
|
|
upstream = await Socket.connect(connectHost, connectPort)
|
|
.timeout(const Duration(seconds: 10));
|
|
} catch (e) {
|
|
_emit('CONNECT $connectHost:$connectPort failed: $e');
|
|
await _send(req.response, 502, {'ok': false, 'error': 'cannot connect to upstream'});
|
|
return;
|
|
}
|
|
|
|
final clientSocket = await req.response.detachSocket(writeHeaders: false);
|
|
clientSocket.add(utf8.encode('HTTP/1.1 200 Connection Established\r\n\r\n'));
|
|
await clientSocket.flush();
|
|
|
|
clientSocket.listen(
|
|
upstream.add,
|
|
onDone: upstream.destroy,
|
|
onError: (_) => upstream.destroy(),
|
|
cancelOnError: true,
|
|
);
|
|
upstream.listen(
|
|
clientSocket.add,
|
|
onDone: clientSocket.destroy,
|
|
onError: (_) => clientSocket.destroy(),
|
|
cancelOnError: true,
|
|
);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Auth token endpoint (v2 architecture — per-request token binding)
|
|
//
|
|
// Component 1 (filter_proxy) and Component 3 (Go binary) call this with
|
|
// {url, method, nonce} for each gated request. A fresh FIDO2 assertion is
|
|
// produced with challenge = SHA256(url|method|nonce). The self-contained
|
|
// assertion bundle is returned as a base64url Bearer token the server can
|
|
// verify without calling back to this service.
|
|
// -------------------------------------------------------------------------
|
|
|
|
Future<void> _handleAuthGetToken(HttpRequest req) async {
|
|
final body = await _readJson(req);
|
|
if (body == null) return;
|
|
|
|
final url = body['url'] as String? ?? '';
|
|
final method = body['method'] as String? ?? '';
|
|
final nonce = body['nonce'] as String? ?? '';
|
|
|
|
if (url.isEmpty || method.isEmpty || nonce.isEmpty) {
|
|
await _send(req.response, 400, {'ok': false, 'error': 'url, method, nonce required'});
|
|
return;
|
|
}
|
|
|
|
if (!_cardAttached || _cardCid == null) {
|
|
await _send(req.response, 503, {'ok': false, 'error': 'card not available'});
|
|
return;
|
|
}
|
|
|
|
// Find first enrolled user with a FIDO2 credential.
|
|
final users = await _db.list();
|
|
Enrollment? enrolled;
|
|
for (final u in users) {
|
|
if (u.hasCredential) { enrolled = u; break; }
|
|
}
|
|
if (enrolled == null) {
|
|
await _send(req.response, 401, {'ok': false, 'error': 'no enrolled credential'});
|
|
return;
|
|
}
|
|
|
|
// Challenge = SHA256(url | "|" | method | "|" | nonce)
|
|
final challenge = Uint8List.fromList(
|
|
sha256.convert(utf8.encode('$url|$method|$nonce')).bytes,
|
|
);
|
|
|
|
GetAssertionResult assertionResult;
|
|
try {
|
|
assertionResult = await getAssertion(_cardCid!, enrolled.credentialDataB64!, challenge: challenge);
|
|
} catch (e) {
|
|
await _send(req.response, 401, {'ok': false, 'error': 'card assertion failed: $e'});
|
|
return;
|
|
}
|
|
|
|
// Self-contained bundle the server can verify without calling back to the phone.
|
|
final bundleJson = jsonEncode({
|
|
'v': 1,
|
|
'url': url,
|
|
'method': method,
|
|
'nonce': nonce,
|
|
'authData': base64Url.encode(assertionResult.authData).replaceAll('=', ''),
|
|
'sig': base64Url.encode(assertionResult.signature).replaceAll('=', ''),
|
|
'cdj': base64Url.encode(utf8.encode(assertionResult.clientDataJson)).replaceAll('=', ''),
|
|
'cred': enrolled.credentialDataB64,
|
|
'user': enrolled.username,
|
|
});
|
|
final token = base64Url.encode(utf8.encode(bundleJson)).replaceAll('=', '');
|
|
|
|
await _send(req.response, 200, {'ok': true, 'token': token, 'username': enrolled.username});
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Health + HTML
|
|
// -------------------------------------------------------------------------
|
|
|
|
Future<void> _handleHealth(HttpRequest req) async {
|
|
await _send(req.response, 200, {
|
|
'ok': true,
|
|
'service': 'k_phone',
|
|
'card': _cardAttached,
|
|
'active_sessions': 0,
|
|
'time': DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
});
|
|
}
|
|
|
|
Future<void> _serveHtmlBytes(HttpRequest req, List<int> bytes) async {
|
|
req.response.statusCode = 200;
|
|
req.response.headers.contentType = ContentType('text', 'html', charset: 'utf-8');
|
|
req.response.headers.contentLength = bytes.length;
|
|
req.response.add(bytes);
|
|
await req.response.close();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Card management
|
|
// -------------------------------------------------------------------------
|
|
|
|
Future<void> _tryOpenCard() async {
|
|
try {
|
|
_cardAttached = await openCard();
|
|
if (!_cardAttached) {
|
|
_emit('No USB card — trying emulator bridge on 10.0.2.2:8772');
|
|
useEmulator(host: '10.0.2.2');
|
|
_cardAttached = await openCard();
|
|
}
|
|
if (_cardAttached) {
|
|
_cardCid = await ctaphidInit();
|
|
_emit('Card open, CID=0x${_cardCid!.toRadixString(16)}');
|
|
} else {
|
|
_emit('No card and no emulator bridge — card operations unavailable');
|
|
}
|
|
} catch (e) {
|
|
_emit('Card open failed: $e');
|
|
_cardAttached = false;
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Helpers
|
|
// -------------------------------------------------------------------------
|
|
|
|
String? _extractBearerToken(HttpRequest req) {
|
|
final auth = req.headers.value('authorization') ?? '';
|
|
if (!auth.startsWith('Bearer ')) return null;
|
|
final token = auth.substring(7).trim();
|
|
return token.isEmpty ? null : token;
|
|
}
|
|
|
|
Future<Map<String, dynamic>?> _readJson(HttpRequest req) async {
|
|
try {
|
|
final bb = BytesBuilder(copy: false);
|
|
await for (final chunk in req) {
|
|
bb.add(chunk);
|
|
}
|
|
final bytes = bb.takeBytes();
|
|
if (bytes.isEmpty) return {};
|
|
return jsonDecode(utf8.decode(bytes)) as Map<String, dynamic>;
|
|
} catch (_) {
|
|
await _send(req.response, 400, {'ok': false, 'error': 'invalid json'});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<void> _drainBody(HttpRequest req) async {
|
|
await req.fold<void>(null, (_, __) {});
|
|
}
|
|
|
|
Future<void> _send(HttpResponse res, int status, Map<String, dynamic> body) async {
|
|
final encoded = utf8.encode(jsonEncode(body));
|
|
res.statusCode = status;
|
|
res.headers.contentType = ContentType.json;
|
|
res.headers.contentLength = encoded.length;
|
|
res.add(encoded);
|
|
await res.close();
|
|
}
|
|
|
|
// Compact user entry for embedded lists in register/delete/list responses.
|
|
Map<String, dynamic> _userSummary(Enrollment e) => {
|
|
'username': e.username,
|
|
'display_name': e.displayName,
|
|
'has_credential': e.hasCredential,
|
|
};
|
|
|
|
Map<String, dynamic> _enrollmentPayload(Enrollment e, {bool? created}) {
|
|
final m = <String, dynamic>{
|
|
'ok': true,
|
|
'username': e.username,
|
|
'display_name': e.displayName,
|
|
'created_at': e.createdAt,
|
|
'updated_at': e.updatedAt,
|
|
'has_credential': e.hasCredential,
|
|
};
|
|
if (created != null) m['created'] = created;
|
|
return m;
|
|
}
|
|
|
|
Future<String?> _parseUsername(HttpRequest req, Map<String, dynamic> body) async {
|
|
try {
|
|
return normalizeUsername(body['username'] as String? ?? '');
|
|
} on ArgumentError catch (e) {
|
|
await _send(req.response, 400, {'ok': false, 'error': e.message.toString()});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<(String, String?)?> _parseUsernameAndDisplay(
|
|
HttpRequest req, Map<String, dynamic> body) async {
|
|
try {
|
|
return (
|
|
normalizeUsername(body['username'] as String? ?? ''),
|
|
normalizeDisplayName(body['display_name'] as String?),
|
|
);
|
|
} on ArgumentError catch (e) {
|
|
await _send(req.response, 400, {'ok': false, 'error': e.message.toString()});
|
|
return null;
|
|
}
|
|
}
|
|
}
|