260 lines
8.1 KiB
Dart
260 lines
8.1 KiB
Dart
// 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 3–32 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;
|
||
}
|
||
}
|