k_card/k_phone/lib/enrollment_db.dart

260 lines
8.1 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Enrollment storage — mirrors k_proxy_app.py ProxyState enrollment logic.
// Persists to a JSON file with the same schema so snapshots are portable.
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
// ---------------------------------------------------------------------------
// Username validation
// ---------------------------------------------------------------------------
final _usernamePattern = RegExp(r'^[a-z0-9](?:[a-z0-9._-]{1,30}[a-z0-9])?$');
String normalizeUsername(String raw) {
final s = raw.trim().toLowerCase();
if (!_usernamePattern.hasMatch(s)) {
throw ArgumentError(
'username must be 332 chars of lowercase letters, digits, dot, underscore, or dash');
}
return s;
}
String? normalizeDisplayName(String? raw) {
final s = (raw ?? '').trim();
if (s.isEmpty) return null;
if (s.length > 64) throw ArgumentError('display_name must be 64 characters or fewer');
return s;
}
// ---------------------------------------------------------------------------
// Model
// ---------------------------------------------------------------------------
class Enrollment {
final String username;
final String? displayName;
final int createdAt;
final int updatedAt;
final String? userIdB64;
final String? credentialDataB64;
const Enrollment({
required this.username,
this.displayName,
required this.createdAt,
required this.updatedAt,
this.userIdB64,
this.credentialDataB64,
});
bool get hasCredential => credentialDataB64 != null;
Enrollment copyWith({
String? displayName,
int? updatedAt,
String? userIdB64,
String? credentialDataB64,
}) =>
Enrollment(
username: username,
displayName: displayName ?? this.displayName,
createdAt: createdAt,
updatedAt: updatedAt ?? this.updatedAt,
userIdB64: userIdB64 ?? this.userIdB64,
credentialDataB64: credentialDataB64 ?? this.credentialDataB64,
);
Map<String, dynamic> toJson() => {
'username': username,
'display_name': displayName,
'created_at': createdAt,
'updated_at': updatedAt,
'user_id_b64': userIdB64,
'credential_data_b64': credentialDataB64,
};
factory Enrollment.fromJson(Map<String, dynamic> m) {
final username = (m['username'] as String? ?? '').trim();
// 'enrolled_at' was the field name in the Python k_proxy JSON schema; accept both for portability.
final createdAt = m['created_at'] as int? ?? m['enrolled_at'] as int? ?? _nowSecs();
return Enrollment(
username: username,
displayName: normalizeDisplayName(m['display_name'] as String?),
createdAt: createdAt,
updatedAt: m['updated_at'] as int? ?? createdAt,
userIdB64: m['user_id_b64'] as String?,
credentialDataB64: m['credential_data_b64'] as String?,
);
}
}
int _nowSecs() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
// ---------------------------------------------------------------------------
// Database
// ---------------------------------------------------------------------------
class EnrollmentDb {
// [baseDir] can be injected in tests to bypass path_provider.
EnrollmentDb({Directory? baseDir}) : _baseDir = baseDir;
final Directory? _baseDir;
final Map<String, Enrollment> _entries = {};
bool _loaded = false;
// Dart isolates are single-threaded so there is no data race on _entries.
// We still serialize async disk I/O: each _serialize call chains its op
// onto _pending so concurrent callers queue up rather than interleave.
Future<void>? _pending;
Future<void> _serialize(Future<void> Function() op) async {
final prev = _pending;
final next = _doAfter(prev, op);
_pending = next;
await next;
}
static Future<void> _doAfter(Future<void>? prev, Future<void> Function() op) async {
if (prev != null) {
try {
await prev;
} catch (_) {} // previous op error must not block the queue
}
await op();
}
// -------------------------------------------------------------------------
// Persistence
// -------------------------------------------------------------------------
Future<File> _dbFile() async {
final dir = _baseDir ?? await getApplicationSupportDirectory();
return File('${dir.path}/k_phone_enrollments.json');
}
Future<void> _load() async {
if (_loaded) return;
_loaded = true;
try {
final f = await _dbFile();
if (!f.existsSync()) return; // no file = fresh install; start with empty DB
final raw = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
final users = raw['users'] as List? ?? [];
for (final item in users) {
final e = Enrollment.fromJson(item as Map<String, dynamic>);
if (e.username.isNotEmpty) _entries[e.username] = e;
}
} catch (_) {
_entries.clear(); // treat a corrupt/unreadable DB as empty; next save overwrites it
}
}
Future<void> _save() async {
final f = await _dbFile();
final users = _entries.values.toList()..sort((a, b) => a.username.compareTo(b.username));
await f.writeAsString(
const JsonEncoder.withIndent(' ').convert({'users': users.map((e) => e.toJson()).toList()}) + '\n',
);
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
Future<void> ensureLoaded() async {
await _serialize(_load);
}
/// Register a new user. Throws [StateError] if already enrolled.
Future<Enrollment> register({
required String username,
String? displayName,
String? userIdB64,
String? credentialDataB64,
}) async {
final canonical = normalizeUsername(username);
final pretty = normalizeDisplayName(displayName);
final now = _nowSecs();
Enrollment? result;
await _serialize(() async {
await _load();
if (_entries.containsKey(canonical)) throw StateError('user already enrolled');
final e = Enrollment(
username: canonical,
displayName: pretty,
createdAt: now,
updatedAt: now,
userIdB64: userIdB64,
credentialDataB64: credentialDataB64,
);
_entries[canonical] = e;
result = e;
await _save();
});
return result!;
}
/// Update display_name (and optionally credential data) for an existing user.
/// Throws [StateError] if not found.
Future<Enrollment> update({
required String username,
String? displayName,
String? userIdB64,
String? credentialDataB64,
}) async {
final canonical = normalizeUsername(username);
final pretty = normalizeDisplayName(displayName);
final now = _nowSecs();
Enrollment? result;
await _serialize(() async {
await _load();
final existing = _entries[canonical];
if (existing == null) throw StateError('user not enrolled');
final updated = existing.copyWith(
displayName: pretty,
updatedAt: now,
userIdB64: userIdB64 ?? existing.userIdB64,
credentialDataB64: credentialDataB64 ?? existing.credentialDataB64,
);
_entries[canonical] = updated;
result = updated;
await _save();
});
return result!;
}
/// Delete a user. Throws [StateError] if not found. Returns deleted entry.
Future<Enrollment> delete(String username) async {
final canonical = normalizeUsername(username);
Enrollment? result;
await _serialize(() async {
await _load();
final existing = _entries.remove(canonical);
if (existing == null) throw StateError('user not enrolled');
result = existing;
await _save();
});
return result!;
}
/// Get a single enrollment or null.
Future<Enrollment?> get(String username) async {
String canonical;
try {
canonical = normalizeUsername(username);
} catch (_) {
return null;
}
await ensureLoaded();
return _entries[canonical];
}
/// List all enrollments sorted by username.
Future<List<Enrollment>> list() async {
await ensureLoaded();
final result = _entries.values.toList()..sort((a, b) => a.username.compareTo(b.username));
return result;
}
}