k_card/k_phone/lib/enrollment_db.dart

254 lines
7.6 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();
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 {
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 with a simple future chain.
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 (_) {}
}
await op();
}
// -------------------------------------------------------------------------
// Persistence
// -------------------------------------------------------------------------
Future<File> _dbFile() async {
final dir = 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;
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();
}
}
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;
}
}