refactor(app,learn): people's files on the device shared in halkyon_app
ADR 0043's recommendation, for Todo's attachments: what Learn had for its files on the device moves into halkyon_app, unchanged but for its names, and Learn uses it from there. - FileCache, DirectoryFileCache (Learn's AssetFileCache): the files by their SHA-256 in a folder of the person's, and which wait to be uploaded. - FileServerGateway, ServerpodFileServerGateway (Learn's AssetServerGateway): halkyon_base's files endpoint, taking the module's caller rather than Learn's client; FileLimitReached, FileRoom. - Their fakes in halkyon_app's testing: MemoryFileCache, FakeFileServerGateway, sha256Of. - SealedFile: the format every app seals a file in (HLKF, version 1), sealing with halkyon_app's ContentCipher; Learn's Privacy seals and opens its files with it, for the same context as before, so every file sealed so far opens as it did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
b7f0aaaec7
commit
5dd7575dd6
6 changed files with 272 additions and 235 deletions
|
|
@ -1,101 +0,0 @@
|
|||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// The files on this device, by their SHA-256, and which of them wait to be
|
||||
/// uploaded.
|
||||
abstract interface class AssetFileCache() {
|
||||
/// The file with [sha256]; null if this device hasn't got it.
|
||||
Future<Uint8List?> read(String sha256);
|
||||
|
||||
/// Keeps [bytes], whose SHA-256 is [sha256].
|
||||
Future<void> write(String sha256, Uint8List bytes);
|
||||
|
||||
/// Where the file with [sha256] is, or would be.
|
||||
String pathOf(String sha256);
|
||||
|
||||
/// The files that wait to be uploaded.
|
||||
Future<Set<String>> pendingUploads();
|
||||
|
||||
/// Notes that the file with [sha256] waits to be uploaded.
|
||||
Future<void> markPending(String sha256);
|
||||
|
||||
/// Notes that the file with [sha256] doesn't wait any more.
|
||||
Future<void> clearPending(String sha256);
|
||||
}
|
||||
|
||||
/// [AssetFileCache] in a folder of the person's: each file named by its hash,
|
||||
/// and an empty file of the same name in `uploads/` for each that waits.
|
||||
///
|
||||
/// A hash is part of a path here, and rows synced from elsewhere carry them,
|
||||
/// so anything but 64 lowercase hex digits is refused.
|
||||
class DirectoryAssetFileCache(final Directory _folder)
|
||||
implements AssetFileCache {
|
||||
Directory get _uploads => Directory(p.join(_folder.path, 'uploads'));
|
||||
|
||||
@override
|
||||
Future<Uint8List?> read(String sha256) async {
|
||||
try {
|
||||
return await File(pathOf(sha256)).readAsBytes();
|
||||
} on PathNotFoundException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(String sha256, Uint8List bytes) async {
|
||||
final file = File(pathOf(sha256));
|
||||
if (file.existsSync()) {
|
||||
return;
|
||||
}
|
||||
await _folder.create(recursive: true);
|
||||
// Written aside and renamed, so no half-written file ever has the name.
|
||||
final partial = File('${file.path}.partial');
|
||||
await partial.writeAsBytes(bytes, flush: true);
|
||||
await partial.rename(file.path);
|
||||
}
|
||||
|
||||
@override
|
||||
String pathOf(String sha256) => p.join(_folder.path, checkedSha256(sha256));
|
||||
|
||||
@override
|
||||
Future<Set<String>> pendingUploads() async {
|
||||
if (!_uploads.existsSync()) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
await for (final entry in _uploads.list())
|
||||
if (entry is File && isSha256(p.basename(entry.path)))
|
||||
p.basename(entry.path),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> markPending(String sha256) async {
|
||||
await _uploads.create(recursive: true);
|
||||
await File(
|
||||
p.join(_uploads.path, checkedSha256(sha256)),
|
||||
).writeAsBytes(const []);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearPending(String sha256) async {
|
||||
try {
|
||||
await File(p.join(_uploads.path, checkedSha256(sha256))).delete();
|
||||
} on PathNotFoundException {
|
||||
// Not waiting.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether [value] is a SHA-256 as files are named by: 64 lowercase hex
|
||||
/// digits.
|
||||
bool isSha256(String value) => _sha256.hasMatch(value);
|
||||
|
||||
/// [value], if it [isSha256]; throws an [ArgumentError] otherwise.
|
||||
String checkedSha256(String value) => isSha256(value)
|
||||
? value
|
||||
: throw ArgumentError.value(value, 'sha256', 'is no SHA-256 in hex');
|
||||
|
||||
final _sha256 = RegExp(r'^[0-9a-f]{64}$');
|
||||
|
|
@ -3,13 +3,13 @@ import 'dart:developer';
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart'
|
||||
show FileCache, FileLimitReached, FileRoom, FileServerGateway, isSha256;
|
||||
import 'package:halkyon_core/halkyon_core.dart'
|
||||
show CoalescingRunner, SerialQueue;
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_file_cache.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_sealing.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_server_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/model/stored_file.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/tree_files.dart';
|
||||
|
||||
|
|
@ -33,8 +33,8 @@ import 'package:halkyon_learn_flutter/src/core/privacy/data/tree_files.dart';
|
|||
/// trees, is a file per key.
|
||||
class AssetRepository(
|
||||
final AssetGateway _rows,
|
||||
final AssetServerGateway _server,
|
||||
final AssetFileCache _files,
|
||||
final FileServerGateway _server,
|
||||
final FileCache _files,
|
||||
final AssetSealing _sealing, {
|
||||
Stream<void> uploadTriggers = const Stream.empty(),
|
||||
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
/// How much room the person's files take on the server, and may take, in
|
||||
/// bytes.
|
||||
typedef FileRoom = ({int usedBytes, int limitBytes});
|
||||
|
||||
/// The server keeps no more of the person's files: they would take more
|
||||
/// room than the person may use, or the instance has left ([instanceFull]).
|
||||
class const FileLimitReached({
|
||||
/// Whether the instance is full, rather than the person's own room.
|
||||
required final bool instanceFull,
|
||||
|
||||
/// What the person may keep, in bytes.
|
||||
required final int limitBytes,
|
||||
}) implements Exception {
|
||||
@override
|
||||
String toString() =>
|
||||
'FileLimitReached(instanceFull: $instanceFull, limit: $limitBytes)';
|
||||
}
|
||||
|
||||
/// The server's copy of the person's files.
|
||||
abstract interface class AssetServerGateway() {
|
||||
/// How to upload the file with [sha256] of [byteSize] bytes; null when the
|
||||
/// server has it already. Throws [FileLimitReached] when it doesn't fit.
|
||||
Future<String?> startUpload(
|
||||
String sha256,
|
||||
int byteSize, {
|
||||
bool encrypted = false,
|
||||
});
|
||||
|
||||
/// Uploads [bytes] as [description] says; whether that worked.
|
||||
Future<bool> upload(String description, Uint8List bytes);
|
||||
|
||||
/// Completes the upload of the file with [sha256]; whether the server keeps
|
||||
/// it, which it does only if its content has that hash. Throws
|
||||
/// [FileLimitReached] when it no longer fits.
|
||||
Future<bool> finishUpload(String sha256, {bool encrypted = false});
|
||||
|
||||
/// How much room the person's files take, and may take.
|
||||
Future<FileRoom> room();
|
||||
|
||||
/// The file of the asset [id]; null when the server has none for this
|
||||
/// person (not uploaded yet, or not theirs).
|
||||
Future<Uint8List?> download(String id);
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart' as base;
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_server_gateway.dart';
|
||||
|
||||
/// [AssetServerGateway] on halkyon_base's `files` endpoint (Halkyon's ADR
|
||||
/// 0043), uploading through Serverpod's own upload route. Learn's own
|
||||
/// `asset` endpoint stays for released apps alone (D7).
|
||||
class ServerpodAssetServerGateway(final protocol.Client _client)
|
||||
implements AssetServerGateway {
|
||||
@override
|
||||
Future<String?> startUpload(
|
||||
String sha256,
|
||||
int byteSize, {
|
||||
bool encrypted = false,
|
||||
}) => _refusing(
|
||||
() => _client.modules.base.files.startUpload(
|
||||
sha256,
|
||||
byteSize,
|
||||
encrypted: encrypted,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<bool> upload(String description, Uint8List bytes) =>
|
||||
protocol.FileUploader(description)
|
||||
.uploadByteData(ByteData.sublistView(bytes));
|
||||
|
||||
@override
|
||||
Future<bool> finishUpload(String sha256, {bool encrypted = false}) =>
|
||||
_refusing(
|
||||
() => _client.modules.base.files.finishUpload(
|
||||
sha256,
|
||||
encrypted: encrypted,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<FileRoom> room() async {
|
||||
final usage = await _client.modules.base.files.usage();
|
||||
return (usedBytes: usage.usedBytes, limitBytes: usage.limitBytes);
|
||||
}
|
||||
|
||||
/// [call], its refusal for want of room as a [FileLimitReached].
|
||||
Future<T> _refusing<T>(Future<T> Function() call) async {
|
||||
try {
|
||||
return await call();
|
||||
} on base.FileLimitExceededException catch (error) {
|
||||
throw FileLimitReached(
|
||||
instanceFull: error.instanceFull,
|
||||
limitBytes: error.limitBytes,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Uint8List?> download(String id) async {
|
||||
try {
|
||||
return Uint8List.sublistView(
|
||||
await _client.modules.base.files.download(
|
||||
protocol.UuidValue.withValidation(id),
|
||||
),
|
||||
);
|
||||
} on base.StoredFileNotFoundException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,9 +5,7 @@ import 'package:flutter/widgets.dart';
|
|||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart'
|
||||
show Client, PublishRefusal, PublishRefusedException, UuidValue;
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_file_cache.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/serverpod_asset_server_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/sound_player.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/sync_asset_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
|
||||
|
|
@ -541,8 +539,8 @@ Future<PersonDependencies> _openPersonData(
|
|||
)..start(sync.synced);
|
||||
final assets = AssetRepository(
|
||||
SyncAssetGateway(db, sync, privacy),
|
||||
ServerpodAssetServerGateway(client),
|
||||
DirectoryAssetFileCache(Directory(p.join(folder.path, 'files'))),
|
||||
ServerpodFileServerGateway(client.modules.base),
|
||||
DirectoryFileCache(Directory(p.join(folder.path, 'files'))),
|
||||
privacy,
|
||||
uploadTriggers: sync.synced,
|
||||
filesAllowed: () => sync.filesAllowed,
|
||||
|
|
|
|||
|
|
@ -2365,63 +2365,21 @@ class Privacy(
|
|||
? utf8.decode(Uint8List.sublistView(envelope.payload))
|
||||
: base64Encode(Uint8List.sublistView(envelope.payload));
|
||||
|
||||
/// What a sealed file starts with, and its format's version.
|
||||
static final _fileMagic = utf8.encode('HLKF');
|
||||
static const _fileVersion = 1;
|
||||
|
||||
@override
|
||||
Uint8List sealFile(
|
||||
Uint8List plain, {
|
||||
required String keyId,
|
||||
required String assetId,
|
||||
}) {
|
||||
final sealed = keys.cipher.seal(
|
||||
plain,
|
||||
keyId: keyId,
|
||||
context: _fileContext(assetId),
|
||||
);
|
||||
final id = utf8.encode(keyId);
|
||||
return Uint8List.fromList([
|
||||
..._fileMagic,
|
||||
_fileVersion,
|
||||
id.length,
|
||||
...id,
|
||||
...sealed.nonce,
|
||||
...sealed.payload,
|
||||
]);
|
||||
}
|
||||
}) => SealedFile.seal(
|
||||
keys.cipher,
|
||||
plain,
|
||||
keyId: keyId,
|
||||
context: _fileContext(assetId),
|
||||
);
|
||||
|
||||
@override
|
||||
Uint8List? openFile(Uint8List sealed, {required String assetId}) {
|
||||
final header = _fileMagic.length + 2;
|
||||
if (sealed.length < header ||
|
||||
!_startsWith(sealed, _fileMagic) ||
|
||||
sealed[_fileMagic.length] != _fileVersion) {
|
||||
return null;
|
||||
}
|
||||
final idLength = sealed[_fileMagic.length + 1];
|
||||
const nonceLength = 24;
|
||||
if (sealed.length < header + idLength + nonceLength) {
|
||||
return null;
|
||||
}
|
||||
final String keyId;
|
||||
try {
|
||||
keyId = utf8.decode(
|
||||
Uint8List.sublistView(sealed, header, header + idLength),
|
||||
);
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
final nonceAt = header + idLength;
|
||||
return keys.cipher.unseal(
|
||||
SealedPayload(
|
||||
keyId: keyId,
|
||||
nonce: Uint8List.sublistView(sealed, nonceAt, nonceAt + nonceLength),
|
||||
payload: Uint8List.sublistView(sealed, nonceAt + nonceLength),
|
||||
),
|
||||
context: _fileContext(assetId),
|
||||
);
|
||||
}
|
||||
Uint8List? openFile(Uint8List sealed, {required String assetId}) =>
|
||||
SealedFile.open(keys.cipher, sealed, context: _fileContext(assetId));
|
||||
|
||||
static List<int> _fileContext(String assetId) =>
|
||||
utf8.encode('asset|$assetId|file');
|
||||
|
|
|
|||
Loading…
Reference in a new issue