feat(privacy): a private studyset's files are sealed with its key (ADR 0019, D5)

- `AssetRepository` seals a file of a private studyset with its tree's key
  for its asset row before hashing it (`AssetSealing`, which `Privacy`
  does): the row's hash and size and the file uploaded are the sealed
  file's, the upload says it is encrypted, and the row's payload, sealed
  too, carries the file name and the hash of what was sealed (asset
  schema 2). A download is checked against the row's hash, opened for its
  row and checked against what was sealed. This device keeps the sealed
  file to upload and the opened one, by its own hash, for what reads files
  by path.
- The same file is a row per key: in a private and a readable studyset, or
  two private trees, it is two.
- Every place adding a file names its studyset: the editors' pictures and
  sounds (`StudysetFiles`, which every screen of a studyset and the PDF
  reader set), PDFs and screenshots, Anki imports, copies and their updates
  (a copy's top studyset is made first, so its files take its key), and
  accepted suggestions (by their item's tree).
- Making a tree private or not, or moving a topic into another tree, moves
  its files: each under another key is added anew under the tree's, the
  content names the new row (whole `asset:` values and a PDF's file, never
  text), and the old row is blanked and deleted once nothing else names it.
- Tests: a private studyset's file never uploads readable; a sealed file
  moved to another row, or under a key not open here, doesn't open; a
  changed file is refused by its hash; one row per key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-21 22:57:08 +02:00
commit e368bba2dd
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
52 changed files with 1402 additions and 228 deletions

View file

@ -2,6 +2,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:halkyon_app/halkyon_app.dart'; import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/asset/widget/studyset_files.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/router/app_shell.dart'; import 'package:halkyon_learn_flutter/src/core/router/app_shell.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
@ -420,13 +421,18 @@ Widget _inStudyset(
builder: (studyset, all) => StudysetLanguageScope( builder: (studyset, all) => StudysetLanguageScope(
studyset: studyset, studyset: studyset,
all: all, all: all,
// Files added on its screens are its own, sealed where it is private
// (ADR 0019).
// Every screen of a studyset washes its colour, its own or the nearest // Every screen of a studyset washes its colour, its own or the nearest
// above it. // above it.
child: ScreenColor( child: StudysetFiles(
color: studysetId: studyset.id,
PaletteColor.byName(colorNameOf(studyset.id, all))?.background ?? child: ScreenColor(
Brand.primary, color:
child: builder(studyset, all), PaletteColor.byName(colorNameOf(studyset.id, all))?.background ??
Brand.primary,
child: builder(studyset, all),
),
), ),
), ),
); );

View file

@ -1,13 +1,18 @@
import 'dart:typed_data'; import 'dart:typed_data';
/// An asset row as stored: the file's id, SHA-256 (lowercase hex), media type /// An asset row as stored: the file's id, SHA-256 (lowercase hex), media type
/// and size, and its payload. /// and size, and its payload, opened. A file of a private studyset (ADR
/// 0019) is sealed with its tree's key, `keyId`; the row's hash and size are
/// then the sealed file's, and `locked` where that key doesn't open here
/// (its payload then blank).
typedef AssetRow = ({ typedef AssetRow = ({
String id, String id,
String sha256, String sha256,
String mimeType, String mimeType,
int byteSize, int byteSize,
ByteData payload, ByteData payload,
String? keyId,
bool locked,
}); });
/// The person's asset rows, one per file of their content (ADR 0004, /// The person's asset rows, one per file of their content (ADR 0004,
@ -20,12 +25,22 @@ abstract interface class AssetGateway() {
/// A row of the file with [sha256]; null if there is none. /// A row of the file with [sha256]; null if there is none.
Future<AssetRow?> bySha256(String sha256); Future<AssetRow?> bySha256(String sha256);
/// Stores a new row and returns its id. /// Every row, sealed or readable.
Future<List<AssetRow>> all();
/// An id for a new row, made before it is stored, as a sealed file is
/// sealed for its row.
String newId();
/// Stores a new row [id], its payload sealed with [keyId] (readable
/// without), and returns its id.
Future<String> insert({ Future<String> insert({
required String id,
required String sha256, required String sha256,
required String mimeType, required String mimeType,
required int byteSize, required int byteSize,
required ByteData payload, required ByteData payload,
String? keyId,
}); });
/// Blanks the row [id]'s payload, then deletes the row: its file goes from /// Blanks the row [id]'s payload, then deletes the row: its file goes from

View file

@ -6,22 +6,49 @@ import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
// the hash, media type and size are columns of their own. // the hash, media type and size are columns of their own.
// //
// Schema 1 holds the name the file came with. // Schema 1 holds the name the file came with.
// Schema 2 adds, for a file of a private studyset (ADR 0019), `plain`: the
// SHA-256 of the file before it was sealed, which the row's own hash, of
// the sealed file, doesn't tell; it lives only in this payload, sealed too.
/// The schema this app writes. /// The schema this app writes for a readable file.
const assetSchemaVersion = 1; const assetSchemaVersion = 1;
/// Encodes a file's [fileName] into its payload. /// The newest schema this app reads.
ByteData encodeAssetPayload({required String fileName}) => const _newestAssetSchema = 2;
encodePayload(assetSchemaVersion, {'fileName': fileName});
/// What a file's payload holds.
typedef AssetFileInfo = ({String fileName, String? plainSha256});
/// Encodes a file's [fileName] into its payload; for a sealed file also the
/// SHA-256 of what was sealed ([plainSha256]).
ByteData encodeAssetPayload({
required String fileName,
String? plainSha256,
}) => plainSha256 == null
? encodePayload(assetSchemaVersion, {'fileName': fileName})
: encodePayload(2, {'fileName': fileName, 'plain': plainSha256});
/// Decodes a file's payload into the name it came with. /// Decodes a file's payload into the name it came with.
/// ///
/// Throws a [FormatException] for anything else, including content written /// Throws a [FormatException] for anything else, including content written
/// by a newer app version in a schema this one doesn't know. /// by a newer app version in a schema this one doesn't know.
String decodeAssetPayload(ByteData payload) { String decodeAssetPayload(ByteData payload) =>
final json = decodePayload(payload, newestSchema: assetSchemaVersion); decodeAssetInfo(payload).fileName;
/// Decodes a file's payload: the name it came with, and for a sealed file
/// the SHA-256 of what was sealed.
///
/// Throws a [FormatException] as [decodeAssetPayload] does.
AssetFileInfo decodeAssetInfo(ByteData payload) {
final json = decodePayload(payload, newestSchema: _newestAssetSchema);
if (json case {'fileName': final String fileName}) { if (json case {'fileName': final String fileName}) {
return fileName; return (
fileName: fileName,
plainSha256: switch (json['plain']) {
final String plain => plain,
_ => null,
},
);
} }
throw const FormatException('Not a file payload.'); throw const FormatException('Not a file payload.');
} }

View file

@ -8,8 +8,10 @@ import 'package:halkyon_core/halkyon_core.dart'
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_file_cache.dart'; 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_gateway.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_payload.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/data/asset_server_gateway.dart';
import 'package:halkyon_learn_flutter/src/core/asset/model/stored_file.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';
/// The files of the person's content (ADR 0004, rule 5): an asset row each, /// The files of the person's content (ADR 0004, rule 5): an asset row each,
/// synced like all content, and the file itself on this device and the /// synced like all content, and the file itself on this device and the
@ -20,12 +22,22 @@ import 'package:halkyon_learn_flutter/src/core/asset/model/stored_file.dart';
/// is usually on the server before its file), one at a time, and what fails /// is usually on the server before its file), one at a time, and what fails
/// waits for the next time. A file this device lacks is downloaded when asked /// waits for the next time. A file this device lacks is downloaded when asked
/// for, checked against its hash and kept. /// for, checked against its hash and kept.
///
/// A file of a private studyset (ADR 0019, D5) is sealed with its tree's key
/// for its row before it is hashed ([AssetSealing]): the row's hash and the
/// file uploaded (`encrypted`) are the sealed file's, and the instance never
/// sees it readable. This device keeps the sealed file under its hash, to
/// upload and check, and the opened one under the hash of what was sealed,
/// which only the row's sealed payload tells, for what reads files by path.
/// The same file in a private and a readable studyset, or in two private
/// trees, is a file per key.
class AssetRepository( class AssetRepository(
final AssetGateway _rows, final AssetGateway _rows,
final AssetServerGateway _server, final AssetServerGateway _server,
final AssetFileCache _files, { final AssetFileCache _files,
final AssetSealing _sealing, {
Stream<void> uploadTriggers = const Stream.empty(), Stream<void> uploadTriggers = const Stream.empty(),
}) { }) implements TreeFiles {
this { this {
_triggers = uploadTriggers.listen((_) => unawaited(uploadPending())); _triggers = uploadTriggers.listen((_) => unawaited(uploadPending()));
} }
@ -63,50 +75,169 @@ class AssetRepository(
/// Whether it stopped: no round starts any more. /// Whether it stopped: no round starts any more.
var _stopped = false; var _stopped = false;
/// Adds [bytes], a file named [fileName] of [mimeType], and returns the id /// Adds [bytes], a file named [fileName] of [mimeType] in the studyset
/// of its asset row: the row of the same file if there is one, else a new /// [studysetId] (sealed with its tree's key where it is private; readable
/// one. Throws an [ArgumentError] for an empty file or one too large for /// without a studyset), and returns the id of its asset row: the row of
/// its kind ([maxBytesOf]). /// the same file under the same key if there is one, else a new one.
/// Throws an [ArgumentError] for an empty file or one too large for its
/// kind ([maxBytesOf]).
Future<String> add( Future<String> add(
Uint8List bytes, { Uint8List bytes, {
required String fileName, required String fileName,
required String mimeType, required String mimeType,
required String? studysetId,
}) async => await addWithKey(
bytes,
fileName: fileName,
mimeType: mimeType,
keyId: studysetId == null ? null : await _sealing.keyOfStudyset(studysetId),
);
/// Adds [bytes] as [add] does, for the item [itemId] of [kind]: sealed
/// with the key of the tree it is in, where that is private.
Future<String> addToItem(
Uint8List bytes, {
required String fileName,
required String mimeType,
required ItemKind kind,
required String itemId,
}) async => await addWithKey(
bytes,
fileName: fileName,
mimeType: mimeType,
keyId: await _sealing.keyOfItem(kind, itemId),
);
/// The id of the asset row of the file with [sha256] where it would be
/// added for the item [itemId] of [kind]; null while there is none.
Future<String?> idInItem(
String sha256, {
required ItemKind kind,
required String itemId,
}) async => await _idUnder(sha256, await _sealing.keyOfItem(kind, itemId));
@override
Future<String> addWithKey(
Uint8List bytes, {
required String fileName,
required String mimeType,
required String? keyId,
}) async { }) async {
if (bytes.isEmpty || bytes.length > maxBytesOf(mimeType)) { if (bytes.isEmpty || bytes.length > maxBytesOf(mimeType)) {
throw ArgumentError.value(bytes.length, 'bytes', 'is out of range'); throw ArgumentError.value(bytes.length, 'bytes', 'is out of range');
} }
return await _adding.run(() async { return await _adding.run(() async {
final hash = sha256.convert(bytes).toString(); final plain = sha256.convert(bytes).toString();
await _files.write(hash, bytes); await _files.write(plain, bytes);
if (await _rows.bySha256(hash) case final row?) { if (keyId == null) {
if (await _rows.bySha256(plain) case final row?
when row.keyId == null) {
return row.id;
}
final id = await _rows.insert(
id: _rows.newId(),
sha256: plain,
mimeType: mimeType,
byteSize: bytes.length,
payload: encodeAssetPayload(fileName: fileName),
);
// Every new row asks the server, even for a file uploaded before:
// the server may have pruned it once no row referred to it.
await _files.markPending(plain);
return id;
}
if (await _sealedRowOf(plain, keyId) case final row?) {
return row.id; return row.id;
} }
final id = await _rows.insert( final id = _rows.newId();
final sealed = _sealing.sealFile(bytes, keyId: keyId, assetId: id);
final hash = sha256.convert(sealed).toString();
await _files.write(hash, sealed);
await _rows.insert(
id: id,
sha256: hash, sha256: hash,
mimeType: mimeType, mimeType: mimeType,
byteSize: bytes.length, byteSize: sealed.length,
payload: encodeAssetPayload(fileName: fileName), payload: encodeAssetPayload(fileName: fileName, plainSha256: plain),
keyId: keyId,
); );
// Every new row asks the server, even for a file uploaded before: the
// server may have pruned it once no row referred to it.
await _files.markPending(hash); await _files.markPending(hash);
return id; return id;
}); });
} }
/// The id of the asset row of the file with [sha256]; null while the /// The row of the file whose content before sealing has [plain] as its
/// person has none. /// hash, sealed with [keyId]; null while there is none.
Future<String?> idOf(String sha256) async => Future<AssetRow?> _sealedRowOf(String plain, String keyId) async {
(await _rows.bySha256(sha256))?.id; for (final row in await _rows.all()) {
if (row.keyId == keyId && !row.locked && _plainOf(row) == plain) {
return row;
}
}
return null;
}
/// The hash of what [row]'s sealed file holds; null for a readable row,
/// or one whose payload doesn't say.
static String? _plainOf(AssetRow row) {
try {
return switch (decodeAssetInfo(row.payload).plainSha256) {
final plain? when isSha256(plain) => plain,
_ => null,
};
} on FormatException {
return null;
}
}
/// The id of the asset row of the file with [sha256] (as it is before any
/// sealing) where it would be added for the studyset [studysetId]: sealed
/// with its tree's key, or readable; null while the person has none.
Future<String?> idOf(String sha256, {required String? studysetId}) async {
final keyId = studysetId == null
? null
: await _sealing.keyOfStudyset(studysetId);
return await _idUnder(sha256, keyId);
}
/// The id of the row of the file with [sha256] sealed with [keyId], or
/// readable without one.
Future<String?> _idUnder(String sha256, String? keyId) async {
if (keyId != null) {
return (await _sealedRowOf(sha256, keyId))?.id;
}
return switch (await _rows.bySha256(sha256)) {
final row? when row.keyId == null => row.id,
_ => null,
};
}
@override
Future<String?> keyIdOf(String id) async => (await _rows.byId(id))?.keyId;
@override
Future<({Uint8List bytes, String fileName, String mimeType})?> plainOf(
String id,
) async => switch (await fileOf(id)) {
final file? => (
bytes: file.bytes,
fileName: file.fileName,
mimeType: file.mimeType,
),
null => null,
};
/// The file of the asset row [id]: from this device, else downloaded. /// The file of the asset row [id]: from this device, else downloaded.
/// Null when it can't be had now: no such row, no connection, or not /// Null when it can't be had now: no such row, no connection, or not
/// uploaded yet. /// uploaded yet.
Future<StoredFile?> fileOf(String id) async { Future<StoredFile?> fileOf(String id) async {
final row = await _rows.byId(id); final row = await _rows.byId(id);
if (row == null || !isSha256(row.sha256)) { if (row == null || row.locked || !isSha256(row.sha256)) {
return null; return null;
} }
if (row.keyId != null) {
return await _sealedFileOf(row);
}
final bytes = final bytes =
await _files.read(row.sha256) ?? await _files.read(row.sha256) ??
await (_downloads[row.sha256] ??= await (_downloads[row.sha256] ??=
@ -133,6 +264,42 @@ class AssetRepository(
); );
} }
/// The file of the sealed [row], opened: as this device keeps it opened,
/// else its sealed file (kept, or downloaded), checked against the row's
/// hash, opened for the row, and checked against the hash of what was
/// sealed.
Future<StoredFile?> _sealedFileOf(AssetRow row) async {
final plain = _plainOf(row);
if (plain == null) {
return null;
}
var bytes = await _files.read(plain);
if (bytes == null) {
final sealed =
await _files.read(row.sha256) ??
await (_downloads[row.sha256] ??= _download(row.id, row.sha256)
.whenComplete(() {
// A block, not an arrow: the future removed is this one,
// and returned it would be waited for by itself.
_downloads.remove(row.sha256);
}));
final opened = sealed == null
? null
: _sealing.openFile(sealed, assetId: row.id);
if (opened == null || sha256.convert(opened).toString() != plain) {
return null;
}
await _files.write(plain, opened);
bytes = opened;
}
return StoredFile(
bytes: bytes,
mimeType: row.mimeType,
fileName: decodeAssetInfo(row.payload).fileName,
path: _files.pathOf(plain),
);
}
Future<Uint8List?> _download(String id, String hash) async { Future<Uint8List?> _download(String id, String hash) async {
try { try {
final bytes = await _server.download(id); final bytes = await _server.download(id);
@ -181,12 +348,18 @@ class AssetRepository(
// Gone from this device: nothing to upload, ever. // Gone from this device: nothing to upload, ever.
return true; return true;
} }
final description = await _server.startUpload(hash, bytes.length); // A sealed file says so, as only a readable one is looked into.
final encrypted = (await _rows.bySha256(hash))?.keyId != null;
final description = await _server.startUpload(
hash,
bytes.length,
encrypted: encrypted,
);
if (description == null) { if (description == null) {
return true; return true;
} }
return await _server.upload(description, bytes) && return await _server.upload(description, bytes) &&
await _server.finishUpload(hash); await _server.finishUpload(hash, encrypted: encrypted);
} }
/// How much room the person's files take on the server, and may take; null /// How much room the person's files take on the server, and may take; null
@ -206,6 +379,7 @@ class AssetRepository(
/// Deletes the row of the file [id], blanked first: the file goes from the /// Deletes the row of the file [id], blanked first: the file goes from the
/// server once nothing refers to it, and stops taking the person's room. /// server once nothing refers to it, and stops taking the person's room.
/// Only for files no content refers to any more. /// Only for files no content refers to any more.
@override
Future<void> delete(String id) => _rows.delete(id); Future<void> delete(String id) => _rows.delete(id);
/// Stops uploading; an upload under way finishes first. /// Stops uploading; an upload under way finishes first.

View file

@ -0,0 +1,33 @@
import 'dart:typed_data';
/// The kinds of item a file can be added to without naming its studyset.
enum ItemKind() {
card,
note,
step,
}
/// How the files of a private studyset are sealed (ADR 0019, D5): with its
/// tree's key, for the asset row they belong to, so a file moved to another
/// row never opens. `Privacy` does it; tests fake it.
abstract interface class AssetSealing() {
/// The key [studysetId]'s tree is encrypted with; null while readable.
Future<String?> keyOfStudyset(String studysetId);
/// The key of the tree the item [id] of [kind] is in; null while
/// readable, or for an item that isn't there.
Future<String?> keyOfItem(ItemKind kind, String id);
/// [plain] sealed with the key [keyId] for the asset row [assetId], as it
/// is stored and uploaded. Throws a [StateError] for a key not open here.
Uint8List sealFile(
Uint8List plain, {
required String keyId,
required String assetId,
});
/// What [sealed] holds, for the asset row [assetId]; null where it
/// doesn't open here (its key isn't open, it was changed, or it belongs
/// to another row).
Uint8List? openFile(Uint8List sealed, {required String assetId});
}

View file

@ -22,7 +22,11 @@ class const FileLimitReached({
abstract interface class AssetServerGateway() { abstract interface class AssetServerGateway() {
/// How to upload the file with [sha256] of [byteSize] bytes; null when the /// How to upload the file with [sha256] of [byteSize] bytes; null when the
/// server has it already. Throws [FileLimitReached] when it doesn't fit. /// server has it already. Throws [FileLimitReached] when it doesn't fit.
Future<String?> startUpload(String sha256, int byteSize); Future<String?> startUpload(
String sha256,
int byteSize, {
bool encrypted = false,
});
/// Uploads [bytes] as [description] says; whether that worked. /// Uploads [bytes] as [description] says; whether that worked.
Future<bool> upload(String description, Uint8List bytes); Future<bool> upload(String description, Uint8List bytes);
@ -30,7 +34,7 @@ abstract interface class AssetServerGateway() {
/// Completes the upload of the file with [sha256]; whether the server keeps /// Completes the upload of the file with [sha256]; whether the server keeps
/// it, which it does only if its content has that hash. Throws /// it, which it does only if its content has that hash. Throws
/// [FileLimitReached] when it no longer fits. /// [FileLimitReached] when it no longer fits.
Future<bool> finishUpload(String sha256); Future<bool> finishUpload(String sha256, {bool encrypted = false});
/// How much room the person's files take, and may take. /// How much room the person's files take, and may take.
Future<FileRoom> room(); Future<FileRoom> room();

View file

@ -8,8 +8,13 @@ import 'package:halkyon_learn_flutter/src/core/asset/data/asset_server_gateway.d
class ServerpodAssetServerGateway(final protocol.Client _client) class ServerpodAssetServerGateway(final protocol.Client _client)
implements AssetServerGateway { implements AssetServerGateway {
@override @override
Future<String?> startUpload(String sha256, int byteSize) => Future<String?> startUpload(
_refusing(() => _client.asset.startUpload(sha256, byteSize)); String sha256,
int byteSize, {
bool encrypted = false,
}) => _refusing(
() => _client.asset.startUpload(sha256, byteSize, encrypted: encrypted),
);
@override @override
Future<bool> upload(String description, Uint8List bytes) => Future<bool> upload(String description, Uint8List bytes) =>
@ -17,8 +22,10 @@ class ServerpodAssetServerGateway(final protocol.Client _client)
.uploadByteData(ByteData.sublistView(bytes)); .uploadByteData(ByteData.sublistView(bytes));
@override @override
Future<bool> finishUpload(String sha256) => Future<bool> finishUpload(String sha256, {bool encrypted = false}) =>
_refusing(() => _client.asset.finishUpload(sha256)); _refusing(
() => _client.asset.finishUpload(sha256, encrypted: encrypted),
);
@override @override
Future<FileRoom> room() async { Future<FileRoom> room() async {

View file

@ -4,13 +4,17 @@ import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol; import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_gateway.dart'; import 'package:halkyon_learn_flutter/src/core/asset/data/asset_gateway.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart'; import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/payload_envelope.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart'; import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// [AssetGateway] on the person's local database, which the sync engine keeps /// [AssetGateway] on the person's local database, which the sync engine keeps
/// in step with their other devices. /// in step with their other devices. A file of a private studyset has its
/// row's payload sealed with its tree's key ([Privacy], ADR 0019).
class SyncAssetGateway( class SyncAssetGateway(
final OfflineSyncDatabaseSession _db, final OfflineSyncDatabaseSession _db,
final SyncService _sync, final SyncService _sync,
final Privacy _privacy,
) implements AssetGateway { ) implements AssetGateway {
@override @override
Future<AssetRow?> byId(String id) async { Future<AssetRow?> byId(String id) async {
@ -21,31 +25,57 @@ class SyncAssetGateway(
// Content can name anything; a name that is no id names no row. // Content can name anything; a name that is no id names no row.
return null; return null;
} }
await _privacy.keys.refresh();
return _row(await protocol.Asset.db.findById(_db, uuid)); return _row(await protocol.Asset.db.findById(_db, uuid));
} }
@override @override
Future<AssetRow?> bySha256(String sha256) async => _row( Future<AssetRow?> bySha256(String sha256) async {
await protocol.Asset.db.findFirstRow( await _privacy.keys.refresh();
_db, return _row(
where: (t) => t.sha256.equals(sha256), await protocol.Asset.db.findFirstRow(
), _db,
); where: (t) => t.sha256.equals(sha256),
),
);
}
@override
Future<List<AssetRow>> all() async {
await _privacy.keys.refresh();
return [
for (final asset in await protocol.Asset.db.find(_db)) ?_row(asset),
];
}
@override
String newId() => Privacy.newId().uuid;
@override @override
Future<String> insert({ Future<String> insert({
required String id,
required String sha256, required String sha256,
required String mimeType, required String mimeType,
required int byteSize, required int byteSize,
required ByteData payload, required ByteData payload,
String? keyId,
}) async { }) async {
final sealed = _privacy.seal(
payload,
keyId: keyId,
table: 'asset',
id: id,
);
final asset = await protocol.Asset.db.insertRow( final asset = await protocol.Asset.db.insertRow(
_db, _db,
protocol.Asset( protocol.Asset(
id: protocol.UuidValue.withValidation(id),
sha256: sha256, sha256: sha256,
mimeType: mimeType, mimeType: mimeType,
byteSize: byteSize, byteSize: byteSize,
payload: payload, keyId: sealed.keyId,
nonce: sealed.nonce,
payload: sealed.payload,
), ),
); );
_sync.requestSync(); _sync.requestSync();
@ -70,13 +100,26 @@ class SyncAssetGateway(
} }
} }
AssetRow? _row(protocol.Asset? asset) => asset == null AssetRow? _row(protocol.Asset? asset) {
? null if (asset == null) {
: ( return null;
id: asset.id!.uuid, }
sha256: asset.sha256, final opened = _privacy.open(
mimeType: asset.mimeType, (keyId: asset.keyId, nonce: asset.nonce, payload: asset.payload),
byteSize: asset.byteSize, table: 'asset',
payload: asset.payload, id: asset.id!.uuid,
); );
return (
id: asset.id!.uuid,
sha256: asset.sha256,
mimeType: asset.mimeType,
byteSize: asset.byteSize,
payload: switch (opened) {
Readable(:final payload) => payload,
Locked() => redactedPayload,
},
keyId: asset.keyId,
locked: opened is Locked,
);
}
} }

View file

@ -0,0 +1,20 @@
import 'package:material_ui/material_ui.dart';
/// The studyset files added beneath belong to (ADR 0019): a private one's
/// are sealed with its tree's key. Every screen of a studyset sets it, so an
/// editor adding a picture or a sound knows where it goes.
class const StudysetFiles({
/// The studyset's id.
required final String studysetId,
required super.child,
super.key,
}) extends InheritedWidget {
/// The studyset files added at [context] belong to; null outside one, where
/// they are readable.
static String? of(BuildContext context) =>
context.getInheritedWidgetOfExactType<StudysetFiles>()?.studysetId;
@override
bool updateShouldNotify(StudysetFiles oldWidget) =>
studysetId != oldWidget.studysetId;
}

View file

@ -472,11 +472,14 @@ Future<PersonDependencies> _openPersonData(
)..start(sync.synced)) )..start(sync.synced))
: null; : null;
final assets = AssetRepository( final assets = AssetRepository(
SyncAssetGateway(db, sync), SyncAssetGateway(db, sync, privacy),
ServerpodAssetServerGateway(client), ServerpodAssetServerGateway(client),
DirectoryAssetFileCache(Directory(p.join(folder.path, 'files'))), DirectoryAssetFileCache(Directory(p.join(folder.path, 'files'))),
privacy,
uploadTriggers: sync.synced, uploadTriggers: sync.synced,
); );
// A tree made private or not, or moved, takes its files along (ADR 0019).
privacy.files = assets;
final pdfSources = PdfSourceRepository( final pdfSources = PdfSourceRepository(
SyncPdfSourceGateway(db, sync, privacy), SyncPdfSourceGateway(db, sync, privacy),
assets, assets,

View file

@ -8,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/core/asset/bloc/sound_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart'; import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/media_type.dart'; import 'package:halkyon_learn_flutter/src/core/asset/data/media_type.dart';
import 'package:halkyon_learn_flutter/src/core/asset/widget/stored_file_image.dart'; import 'package:halkyon_learn_flutter/src/core/asset/widget/stored_file_image.dart';
import 'package:halkyon_learn_flutter/src/core/asset/widget/studyset_files.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart'; import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart'; import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
@ -88,6 +89,7 @@ Future<void> _insertFile(
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.maybeOf(context); final messenger = ScaffoldMessenger.maybeOf(context);
final assets = PersonDependencies.of(context).assets; final assets = PersonDependencies.of(context).assets;
final studysetId = StudysetFiles.of(context);
final image = kind == _FileKind.image; final image = kind == _FileKind.image;
final file = await Dependencies.of(context).files.pick( final file = await Dependencies.of(context).files.pick(
label: image ? l10n.fileImages : l10n.fileSounds, label: image ? l10n.fileImages : l10n.fileSounds,
@ -111,7 +113,12 @@ Future<void> _insertFile(
} }
final String id; final String id;
try { try {
id = await assets.add(file.bytes, fileName: file.name, mimeType: mediaType); id = await assets.add(
file.bytes,
fileName: file.name,
mimeType: mediaType,
studysetId: studysetId,
);
} on Exception catch (error) { } on Exception catch (error) {
log('Keeping a picked file failed', name: 'Editor', error: error); log('Keeping a picked file failed', name: 'Editor', error: error);
tell(l10n.fileNotKept); tell(l10n.fileNotKept);

View file

@ -1,9 +1,12 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol; import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_sealing.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/content_keys.dart'; import 'package:halkyon_learn_flutter/src/core/privacy/data/content_keys.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/payload_envelope.dart'; import 'package:halkyon_learn_flutter/src/core/privacy/data/payload_envelope.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/tree_files.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart'; import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// What the synced gateways seal and open with (ADR 0019): the person's /// What the synced gateways seal and open with (ADR 0019): the person's
@ -15,7 +18,11 @@ class Privacy(
/// The person's keys on this device. /// The person's keys on this device.
final ContentKeys keys, final ContentKeys keys,
) { ) implements AssetSealing {
/// The person's files, for moving a tree's to another key; set once they
/// are open, as they seal through this.
TreeFiles? files;
/// Seals and opens every synced payload. /// Seals and opens every synced payload.
late final envelope = PayloadEnvelope(keys.cipher); late final envelope = PayloadEnvelope(keys.cipher);
@ -29,9 +36,17 @@ class Privacy(
/// The key [studysetId]'s tree is encrypted with: its top-level /// The key [studysetId]'s tree is encrypted with: its top-level
/// studyset's; null for a readable tree, or a studyset that isn't there. /// studyset's; null for a readable tree, or a studyset that isn't there.
@override
Future<String?> keyOfStudyset(String studysetId) async => Future<String?> keyOfStudyset(String studysetId) async =>
(_treeKeys ??= await _readTreeKeys())[studysetId]; (_treeKeys ??= await _readTreeKeys())[studysetId];
@override
Future<String?> keyOfItem(ItemKind kind, String id) => switch (kind) {
ItemKind.card => keyOfCard(id),
ItemKind.note => keyOfNote(id),
ItemKind.step => keyOfStep(id),
};
/// The key of the tree the card [cardId] is in. /// The key of the tree the card [cardId] is in.
Future<String?> keyOfCard(String cardId) async { Future<String?> keyOfCard(String cardId) async {
final card = await protocol.Card.db.findById(_db, _uuid(cardId)); final card = await protocol.Card.db.findById(_db, _uuid(cardId));
@ -109,57 +124,21 @@ class Privacy(
/// readable with null (ADR 0019): the studysets, their cards', notes' and /// readable with null (ADR 0019): the studysets, their cards', notes' and
/// steps' revisions, their paths, the answers given in them and their /// steps' revisions, their paths, the answers given in them and their
/// PDFs, each opened with the key it has and written back in place (the /// PDFs, each opened with the key it has and written back in place (the
/// payload's columns only, as blanking is). A row that doesn't open here /// payload's columns only, as blanking is). Its files go along ([files]):
/// stays as it is. Returns how many rows it couldn't open. /// each one under another key is added anew under [keyId], the tree's
/// content naming the new one (whole `asset:` values and a PDF's file),
/// and the old row, once nothing else names it, is blanked and deleted. A
/// row or file that doesn't open here stays as it is. Returns how many
/// couldn't be opened.
Future<int> rekeyTree(String studysetId, String? keyId) async { Future<int> rekeyTree(String studysetId, String? keyId) async {
final tree = await treeOf(studysetId); final tree = await treeOf(studysetId);
final ids = {for (final id in tree) _uuid(id)}; final ids = {for (final id in tree) _uuid(id)};
var locked = 0; var locked = 0;
/// [row]'s payload in [table] under [keyId], or null to leave it. final studysets = await protocol.Studyset.db.find(
Envelope? rekeyed(
String table,
String id,
Envelope current, {
String column = 'payload',
}) {
if (current.keyId == keyId) {
return null;
}
switch (open(current, table: table, id: id, column: column)) {
case Readable(:final payload):
return seal(
payload,
keyId: keyId,
table: table,
id: id,
column: column,
);
case Locked():
locked++;
return null;
}
}
// Each row on its own: the sync engine's session writes one at a time,
// and a row rewritten is whole either way.
for (final row in await protocol.Studyset.db.find(
_db, _db,
where: (t) => t.id.inSet(ids), where: (t) => t.id.inSet(ids),
)) { );
if (rekeyed('studyset', row.id!.uuid, (
keyId: row.keyId,
nonce: row.nonce,
payload: row.payload,
))
case final e?) {
await protocol.Studyset.db.updateRow(
_db,
row.copyWith(keyId: e.keyId, nonce: e.nonce, payload: e.payload),
columns: (t) => [t.keyId, t.nonce, t.payload],
);
}
}
final cards = { final cards = {
for (final card in await protocol.Card.db.find( for (final card in await protocol.Card.db.find(
_db, _db,
@ -167,23 +146,10 @@ class Privacy(
)) ))
card.id!, card.id!,
}; };
for (final row in await protocol.CardRevision.db.find( final cardRevisions = await protocol.CardRevision.db.find(
_db, _db,
where: (t) => t.cardId.inSet(cards), where: (t) => t.cardId.inSet(cards),
)) { );
if (rekeyed('card_revision', row.id!.uuid, (
keyId: row.keyId,
nonce: row.nonce,
payload: row.payload,
))
case final e?) {
await protocol.CardRevision.db.updateRow(
_db,
row.copyWith(keyId: e.keyId, nonce: e.nonce, payload: e.payload),
columns: (t) => [t.keyId, t.nonce, t.payload],
);
}
}
final notes = { final notes = {
for (final note in await protocol.Note.db.find( for (final note in await protocol.Note.db.find(
_db, _db,
@ -191,16 +157,137 @@ class Privacy(
)) ))
note.id!, note.id!,
}; };
for (final row in await protocol.NoteRevision.db.find( final noteRevisions = await protocol.NoteRevision.db.find(
_db, _db,
where: (t) => t.noteId.inSet(notes), where: (t) => t.noteId.inSet(notes),
)) { );
if (rekeyed('note_revision', row.id!.uuid, ( final paths = await protocol.LearningPath.db.find(
keyId: row.keyId, _db,
nonce: row.nonce, where: (t) => t.studysetId.inSet(ids),
payload: row.payload, );
)) final steps = {
case final e?) { for (final step in await protocol.LearningPathStep.db.find(
_db,
where: (t) => t.pathId.inSet({for (final path in paths) path.id!}),
))
step.id!,
};
final stepRevisions = await protocol.LearningPathStepRevision.db.find(
_db,
where: (t) => t.stepId.inSet(steps),
);
final pdfs = await protocol.PdfSource.db.find(
_db,
where: (t) => t.studysetId.inSet(ids),
);
// Every row of the tree with an envelope, opened once.
final rows = <({String table, String id, Envelope current, Opened opened})>[
for (final row in studysets)
_opened('studyset', row.id!.uuid, row.keyId, row.nonce, row.payload),
for (final row in cardRevisions)
_opened(
'card_revision',
row.id!.uuid,
row.keyId,
row.nonce,
row.payload,
),
for (final row in noteRevisions)
_opened(
'note_revision',
row.id!.uuid,
row.keyId,
row.nonce,
row.payload,
),
for (final row in paths)
_opened(
'learning_path',
row.id!.uuid,
row.keyId,
row.nonce,
row.payload,
),
for (final row in stepRevisions)
_opened(
'learning_path_step_revision',
row.id!.uuid,
row.keyId,
row.nonce,
row.payload,
),
for (final row in pdfs)
_opened('pdf_source', row.id!.uuid, row.keyId, row.nonce, row.payload),
];
// The tree's files under another key, each added anew under this one.
final swaps = <String, String>{};
if (files case final files?) {
final named = <String>{
for (final row in rows)
if (row.opened case Readable(:final payload)) ...assetsIn(payload),
};
for (final asset in named) {
if (await files.keyIdOf(asset) == keyId) {
continue;
}
final plain = await files.plainOf(asset);
if (plain == null) {
locked++;
continue;
}
swaps[asset] = await files.addWithKey(
plain.bytes,
fileName: plain.fileName,
mimeType: plain.mimeType,
keyId: keyId,
);
}
}
// Each row anew: sealed with the tree's key, naming its files' new rows.
final rewritten = <String, Envelope>{};
for (final row in rows) {
switch (row.opened) {
case Locked():
if (row.current.keyId != keyId) {
locked++;
}
case Readable(:final payload):
final swapped = withAssets(payload, swaps);
if (row.current.keyId != keyId || !identical(swapped, payload)) {
rewritten['${row.table}|${row.id}'] = seal(
swapped,
keyId: keyId,
table: row.table,
id: row.id,
);
}
}
}
Envelope? anew(String table, protocol.UuidValue? id) =>
rewritten['$table|${id!.uuid}'];
for (final row in studysets) {
if (anew('studyset', row.id) case final e?) {
await protocol.Studyset.db.updateRow(
_db,
row.copyWith(keyId: e.keyId, nonce: e.nonce, payload: e.payload),
columns: (t) => [t.keyId, t.nonce, t.payload],
);
}
}
for (final row in cardRevisions) {
if (anew('card_revision', row.id) case final e?) {
await protocol.CardRevision.db.updateRow(
_db,
row.copyWith(keyId: e.keyId, nonce: e.nonce, payload: e.payload),
columns: (t) => [t.keyId, t.nonce, t.payload],
);
}
}
for (final row in noteRevisions) {
if (anew('note_revision', row.id) case final e?) {
await protocol.NoteRevision.db.updateRow( await protocol.NoteRevision.db.updateRow(
_db, _db,
row.copyWith(keyId: e.keyId, nonce: e.nonce, payload: e.payload), row.copyWith(keyId: e.keyId, nonce: e.nonce, payload: e.payload),
@ -208,17 +295,8 @@ class Privacy(
); );
} }
} }
final paths = await protocol.LearningPath.db.find(
_db,
where: (t) => t.studysetId.inSet(ids),
);
for (final row in paths) { for (final row in paths) {
if (rekeyed('learning_path', row.id!.uuid, ( if (anew('learning_path', row.id) case final e?) {
keyId: row.keyId,
nonce: row.nonce,
payload: row.payload,
))
case final e?) {
await protocol.LearningPath.db.updateRow( await protocol.LearningPath.db.updateRow(
_db, _db,
row.copyWith(keyId: e.keyId, nonce: e.nonce, payload: e.payload), row.copyWith(keyId: e.keyId, nonce: e.nonce, payload: e.payload),
@ -226,6 +304,160 @@ class Privacy(
); );
} }
} }
for (final row in stepRevisions) {
if (anew('learning_path_step_revision', row.id) case final e?) {
await protocol.LearningPathStepRevision.db.updateRow(
_db,
row.copyWith(keyId: e.keyId, nonce: e.nonce, payload: e.payload),
columns: (t) => [t.keyId, t.nonce, t.payload],
);
}
}
for (final row in pdfs) {
if (anew('pdf_source', row.id) case final e?) {
await protocol.PdfSource.db.updateRow(
_db,
row.copyWith(keyId: e.keyId, nonce: e.nonce, payload: e.payload),
columns: (t) => [t.keyId, t.nonce, t.payload],
);
}
}
for (final row in await protocol.LearningPathAnswer.db.find(
_db,
where: (t) => t.stepId.inSet(steps),
)) {
final answer = row.answer;
final current = answer == null
? null
: answerEnvelope(row.keyId, row.nonce, answer);
if (current == null || current.keyId == keyId) {
continue;
}
switch (open(
current,
table: 'learning_path_answer',
id: row.id!.uuid,
column: 'answer',
)) {
case Readable(:final payload):
final e = seal(
payload,
keyId: keyId,
table: 'learning_path_answer',
id: row.id!.uuid,
column: 'answer',
);
await protocol.LearningPathAnswer.db.updateRow(
_db,
row.copyWith(
keyId: e.keyId,
nonce: e.nonce,
answer: answerText(e),
),
columns: (t) => [t.keyId, t.nonce, t.answer],
);
case Locked():
locked++;
}
}
// The old rows of the files moved, once nothing else names them.
if (swaps.isNotEmpty) {
final namedElsewhere = await _assetsNamedOutside(tree);
for (final old in swaps.keys) {
if (!namedElsewhere.contains(old)) {
await files?.delete(old);
}
}
}
forgetTrees();
return locked;
}
({String table, String id, Envelope current, Opened opened}) _opened(
String table,
String id,
String? keyId,
ByteData? nonce,
ByteData payload,
) {
final current = (keyId: keyId, nonce: nonce, payload: payload);
return (
table: table,
id: id,
current: current,
opened: open(current, table: table, id: id),
);
}
/// The asset rows the studysets outside [tree] name, with everything in
/// them.
Future<Set<String>> _assetsNamedOutside(Set<String> tree) async {
final inside = {for (final id in tree) _uuid(id)};
final named = <String>{};
void add(String table, protocol.UuidValue? id, Envelope envelope) {
if (open(envelope, table: table, id: id!.uuid) case Readable(
:final payload,
)) {
named.addAll(assetsIn(payload));
}
}
for (final row in await protocol.Studyset.db.find(
_db,
where: (t) => t.id.notInSet(inside),
)) {
add('studyset', row.id, (
keyId: row.keyId,
nonce: row.nonce,
payload: row.payload,
));
}
final cards = {
for (final card in await protocol.Card.db.find(
_db,
where: (t) => t.studysetId.notInSet(inside),
))
card.id!,
};
for (final row in await protocol.CardRevision.db.find(
_db,
where: (t) => t.cardId.inSet(cards),
)) {
add('card_revision', row.id, (
keyId: row.keyId,
nonce: row.nonce,
payload: row.payload,
));
}
final notes = {
for (final note in await protocol.Note.db.find(
_db,
where: (t) => t.studysetId.notInSet(inside),
))
note.id!,
};
for (final row in await protocol.NoteRevision.db.find(
_db,
where: (t) => t.noteId.inSet(notes),
)) {
add('note_revision', row.id, (
keyId: row.keyId,
nonce: row.nonce,
payload: row.payload,
));
}
final paths = await protocol.LearningPath.db.find(
_db,
where: (t) => t.studysetId.notInSet(inside),
);
for (final row in paths) {
add('learning_path', row.id, (
keyId: row.keyId,
nonce: row.nonce,
payload: row.payload,
));
}
final steps = { final steps = {
for (final step in await protocol.LearningPathStep.db.find( for (final step in await protocol.LearningPathStep.db.find(
_db, _db,
@ -237,67 +469,95 @@ class Privacy(
_db, _db,
where: (t) => t.stepId.inSet(steps), where: (t) => t.stepId.inSet(steps),
)) { )) {
if (rekeyed( add('learning_path_step_revision', row.id, (
'learning_path_step_revision', keyId: row.keyId,
row.id!.uuid, nonce: row.nonce,
(keyId: row.keyId, nonce: row.nonce, payload: row.payload), payload: row.payload,
) ));
case final e?) {
await protocol.LearningPathStepRevision.db.updateRow(
_db,
row.copyWith(keyId: e.keyId, nonce: e.nonce, payload: e.payload),
columns: (t) => [t.keyId, t.nonce, t.payload],
);
}
}
for (final row in await protocol.LearningPathAnswer.db.find(
_db,
where: (t) => t.stepId.inSet(steps),
)) {
final answer = row.answer;
if (answer == null) {
continue;
}
final current = answerEnvelope(row.keyId, row.nonce, answer);
final e = current == null
? null
: rekeyed(
'learning_path_answer',
row.id!.uuid,
current,
column: 'answer',
);
if (e != null) {
await protocol.LearningPathAnswer.db.updateRow(
_db,
row.copyWith(
keyId: e.keyId,
nonce: e.nonce,
answer: answerText(e),
),
columns: (t) => [t.keyId, t.nonce, t.answer],
);
}
} }
for (final row in await protocol.PdfSource.db.find( for (final row in await protocol.PdfSource.db.find(
_db, _db,
where: (t) => t.studysetId.inSet(ids), where: (t) => t.studysetId.notInSet(inside),
)) { )) {
if (rekeyed('pdf_source', row.id!.uuid, ( add('pdf_source', row.id, (
keyId: row.keyId, keyId: row.keyId,
nonce: row.nonce, nonce: row.nonce,
payload: row.payload, payload: row.payload,
)) ));
case final e?) { }
await protocol.PdfSource.db.updateRow( return named;
_db, }
row.copyWith(keyId: e.keyId, nonce: e.nonce, payload: e.payload),
columns: (t) => [t.keyId, t.nonce, t.payload], /// The asset rows [payload] names: whole `asset:<id>` values (images,
); /// sounds, screenshots) and a PDF's `asset`.
static Set<String> assetsIn(ByteData payload) {
final named = <String>{};
void visit(Object? value, [String? key]) {
switch (value) {
case final String text when text.startsWith('asset:'):
named.add(text.substring('asset:'.length));
case final String text when key == 'asset':
named.add(text);
case final Map<String, Object?> map:
map.forEach((key, value) => visit(value, key));
case final List<Object?> list:
list.forEach(visit);
} }
} }
forgetTrees();
return locked; visit(_json(payload));
return named;
}
/// [payload] naming the asset rows [swaps] gives for those it names (whole
/// values alone, never text); [payload] itself where it names none.
static ByteData withAssets(ByteData payload, Map<String, String> swaps) {
if (swaps.isEmpty) {
return payload;
}
var changed = false;
Object? swapped(Object? value, [String? key]) {
switch (value) {
case final String text
when text.startsWith('asset:') &&
swaps.containsKey(text.substring('asset:'.length)):
changed = true;
return 'asset:${swaps[text.substring('asset:'.length)]}';
case final String text when key == 'asset' && swaps.containsKey(text):
changed = true;
return swaps[text];
case final Map<String, Object?> map:
return {
for (final MapEntry(:key, :value) in map.entries)
key: swapped(value, key),
};
case final List<Object?> list:
return [for (final item in list) swapped(item)];
default:
return value;
}
}
final json = _json(payload);
if (json == null) {
return payload;
}
final result = swapped(json);
return changed
? ByteData.sublistView(utf8.encode(jsonEncode(result)))
: payload;
}
/// [payload]'s JSON; null for a blank one or one that isn't JSON.
static Object? _json(ByteData payload) {
if (payload.lengthInBytes == 0) {
return null;
}
try {
return jsonDecode(utf8.decode(Uint8List.sublistView(payload)));
} on FormatException {
return null;
}
} }
/// A path answer's text (its `answer` column) as an envelope: sealed, it /// A path answer's text (its `answer` column) as an envelope: sealed, it
@ -331,6 +591,76 @@ class Privacy(
? utf8.decode(Uint8List.sublistView(envelope.payload)) ? utf8.decode(Uint8List.sublistView(envelope.payload))
: base64Encode(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,
]);
}
@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),
);
}
static List<int> _fileContext(String assetId) =>
utf8.encode('asset|$assetId|file');
static bool _startsWith(Uint8List bytes, List<int> prefix) {
for (var i = 0; i < prefix.length; i++) {
if (bytes[i] != prefix[i]) {
return false;
}
}
return true;
}
/// A new row's id, made here so its payload is sealed for it before it is /// A new row's id, made here so its payload is sealed for it before it is
/// written: a UUID v7, as the database would make (ADR 0001, rule 7). /// written: a UUID v7, as the database would make (ADR 0001, rule 7).
static protocol.UuidValue newId() => const protocol.Uuid().v7obj(); static protocol.UuidValue newId() => const protocol.Uuid().v7obj();

View file

@ -0,0 +1,29 @@
import 'dart:typed_data';
/// What moving a tree's files to another key needs of the person's files
/// (ADR 0019): `AssetRepository` does it.
abstract interface class TreeFiles() {
/// The file of the asset row [id] as it was before sealing, with its name
/// and media type; null where it can't be had now (not on this device and
/// no connection, or locked).
Future<({Uint8List bytes, String fileName, String mimeType})?> plainOf(
String id,
);
/// The key the asset row [id] is sealed with; null for a readable one.
Future<String?> keyIdOf(String id);
/// Adds [bytes] as a file sealed with [keyId] (readable without), and
/// returns its asset row's id: one of that key holding the same file, if
/// there is one.
Future<String> addWithKey(
Uint8List bytes, {
required String fileName,
required String mimeType,
required String? keyId,
});
/// Blanks and deletes the asset row [id], which nothing refers to any
/// more.
Future<void> delete(String id);
}

View file

@ -104,7 +104,7 @@ class AnkiImporter(
final assetOf = <String, String>{}; final assetOf = <String, String>{};
var skippedFiles = 0; var skippedFiles = 0;
for (final name in media) { for (final name in media) {
final id = await _addFile(package, name); final id = await _addFile(package, name, studysetId);
if (id == null) { if (id == null) {
skippedFiles++; skippedFiles++;
} else { } else {
@ -220,9 +220,14 @@ class AnkiImporter(
); );
} }
/// Adds the media file [name] as a file of the person's; null when it /// Adds the media file [name] as a file of the person's in the studyset
/// can't be: a kind the app doesn't show, missing, or too large. /// [studysetId] (sealed where it is private, ADR 0019); null when it can't
Future<String?> _addFile(AnkiPackage package, String name) async { /// be: a kind the app doesn't show, missing, or too large.
Future<String?> _addFile(
AnkiPackage package,
String name,
String studysetId,
) async {
final mimeType = mediaTypeOf(name); final mimeType = mediaTypeOf(name);
final bytes = mimeType == null ? null : package.mediaBytes(name); final bytes = mimeType == null ? null : package.mediaBytes(name);
if (mimeType == null || if (mimeType == null ||
@ -232,7 +237,12 @@ class AnkiImporter(
return null; return null;
} }
try { try {
return await _assets.add(bytes, fileName: name, mimeType: mimeType); return await _assets.add(
bytes,
fileName: name,
mimeType: mimeType,
studysetId: studysetId,
);
} on Exception catch (error) { } on Exception catch (error) {
// The device couldn't keep it: the cards go without it. // The device couldn't keep it: the cards go without it.
log('Left out the file $name', name: 'Anki', error: error); log('Left out the file $name', name: 'Anki', error: error);

View file

@ -120,8 +120,10 @@ class CopyUpdater(
'sha256': final String sha256, 'sha256': final String sha256,
'mimeType': final String mimeType, 'mimeType': final String mimeType,
}) { }) {
// The copy's files are sealed with its key where it is private
// (ADR 0019): the same file elsewhere is another.
final mine = final mine =
await _assets.idOf(sha256) ?? await _assets.idOf(sha256, studysetId: rootId) ??
(identical(release, newer) (identical(release, newer)
? await _assets.add( ? await _assets.add(
await fileOf(sha256), await fileOf(sha256),
@ -131,6 +133,7 @@ class CopyUpdater(
_ => sha256, _ => sha256,
}, },
mimeType: mimeType, mimeType: mimeType,
studysetId: rootId,
) )
: null); : null);
if (mine != null) { if (mine != null) {

View file

@ -116,7 +116,7 @@ class ReleaseCopier(
Map<String, TagLook> tags = const {}, Map<String, TagLook> tags = const {},
}) async { }) async {
// Read all of it first: a copy is whole or not made. // Read all of it first: a copy is whole or not made.
_read(release, const {}); final unnamed = _read(release, const {});
final files = switch (release.items['files.json']) { final files = switch (release.items['files.json']) {
final Map<String, Object?> index => index, final Map<String, Object?> index => index,
null => const <String, Object?>{}, null => const <String, Object?>{},
@ -129,7 +129,21 @@ class ReleaseCopier(
var done = 0; var done = 0;
void step() => onProgress?.call(++done / total); void step() => onProgress?.call(++done / total);
// The files first, as the person's own: the content then names them. // Each item's id in the release, and the id it has now.
final ids = <String, String>{};
// The top studyset first: its files are sealed with its key where it
// is private, as a new studyset is (ADR 0019).
final studysetIds = <String, String>{};
final rootItem = unnamed.studysets['']!;
StudysetContent rootContent(StudysetContent content) => origin == null
? content
: _withOrigin(content, origin(rootItem.id), location: location);
final root = studysetIds[''] = ids[rootItem.id] = await _studysets.create(
rootContent(rootItem.content),
);
step();
// The files, as the person's own in it: the content then names them.
final assetIds = <String, String>{}; final assetIds = <String, String>{};
for (final MapEntry(key: id, value: file) in files.entries) { for (final MapEntry(key: id, value: file) in files.entries) {
if (file case { if (file case {
@ -143,27 +157,19 @@ class ReleaseCopier(
_ => sha256, _ => sha256,
}, },
mimeType: mimeType, mimeType: mimeType,
studysetId: root,
); );
} }
step(); step();
} }
final read = _read(release, assetIds); final read = _read(release, assetIds);
// Each item's id in the release, and the id it has now. // The top studyset names its own files now too.
final ids = <String, String>{}; final withFiles = rootContent(read.studysets['']!.content);
if (withFiles != rootContent(rootItem.content)) {
await _studysets.updateContent(root, withFiles);
}
// The studysets, each after the one it is in, in their order. // The studysets inside it, each after the one it is in, in their order.
final studysetIds = <String, String>{};
final rootItem = read.studysets['']!;
studysetIds[''] = ids[rootItem.id] = await _studysets.create(
origin == null
? rootItem.content
: _withOrigin(
rootItem.content,
origin(rootItem.id),
location: location,
),
);
step();
for (final directory in read.directories) { for (final directory in read.directories) {
if (directory.isEmpty) { if (directory.isEmpty) {
continue; continue;

View file

@ -3,6 +3,7 @@ import 'dart:typed_data';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart'; import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_sealing.dart';
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart'; import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart'; import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/profile/public_person.dart'; import 'package:halkyon_learn_flutter/src/core/profile/public_person.dart';
@ -159,20 +160,29 @@ class FeedbackRepository(
if (proposed == null) { if (proposed == null) {
throw SuggestionNotTaken(); throw SuggestionNotTaken();
} }
// The writer's files as the author's own. // The writer's files as the author's own, in the item's studyset:
// sealed with its key where it is private (ADR 0019).
final kind = switch (thread.target.kind) {
FeedbackTargetKind.card => ItemKind.card,
FeedbackTargetKind.note => ItemKind.note,
FeedbackTargetKind.step => ItemKind.step,
FeedbackTargetKind.studyset => throw SuggestionNotTaken(),
};
final item = thread.target.item;
var text = jsonEncode(proposed); var text = jsonEncode(proposed);
for (final file in thread.files) { for (final file in thread.files) {
final own = final own =
await _assets.idOf(file.sha256) ?? await _assets.idInItem(file.sha256, kind: kind, itemId: item) ??
await _assets.add( await _assets.addToItem(
await _server.file(thread.threadId, file.sha256), await _server.file(thread.threadId, file.sha256),
fileName: file.fileName.isEmpty ? file.sha256 : file.fileName, fileName: file.fileName.isEmpty ? file.sha256 : file.fileName,
mimeType: file.mimeType, mimeType: file.mimeType,
kind: kind,
itemId: item,
); );
text = text.replaceAll(file.assetId, own); text = text.replaceAll(file.assetId, own);
} }
final payload = ByteData.sublistView(utf8.encode(text)); final payload = ByteData.sublistView(utf8.encode(text));
final item = thread.target.item;
try { try {
switch (thread.target.kind) { switch (thread.target.kind) {
case FeedbackTargetKind.card: case FeedbackTargetKind.card:

View file

@ -139,6 +139,7 @@ class PdfSourceRepository(
bytes, bytes,
fileName: fileName, fileName: fileName,
mimeType: AssetRepository.pdfMediaType, mimeType: AssetRepository.pdfMediaType,
studysetId: studysetId,
); );
if (!evenIfThere) { if (!evenIfThere) {
for (final source in await sourcesOf(studysetId)) { for (final source in await sourcesOf(studysetId)) {

View file

@ -5,6 +5,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart'; import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/asset/widget/studyset_files.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart'; import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart'; import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart'; import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
@ -118,6 +119,8 @@ class _PdfCaptureOverlayState() extends State<PdfCaptureOverlay> {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.maybeOf(context); final messenger = ScaffoldMessenger.maybeOf(context);
setState(() => _working = true); setState(() => _working = true);
// Its studyset's: sealed with its key where it is private (ADR 0019).
final studysetId = StudysetFiles.of(context);
final number = widget.controller.page; final number = widget.controller.page;
final region = Rect.fromLTRB( final region = Rect.fromLTRB(
(drawn.left - page.left) / page.width, (drawn.left - page.left) / page.width,
@ -137,6 +140,7 @@ class _PdfCaptureOverlayState() extends State<PdfCaptureOverlay> {
png, png,
fileName: 'page-$number.png', fileName: 'page-$number.png',
mimeType: 'image/png', mimeType: 'image/png',
studysetId: studysetId,
); );
messenger?.showSnackBar( messenger?.showSnackBar(
SnackBar(content: Text(l10n.readerScreenshotSaved)), SnackBar(content: Text(l10n.readerScreenshotSaved)),

View file

@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/asset/widget/studyset_files.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart'; import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
@ -55,9 +56,14 @@ class const PdfReaderScreen({
builder: (context, state) { builder: (context, state) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
return switch (state) { return switch (state) {
PdfReaderStateReady(:final source) => _StudysetLanguages( // Screenshots and files of the cards written beside it are the
// PDF's studyset's, sealed where it is private (ADR 0019).
PdfReaderStateReady(:final source) => StudysetFiles(
studysetId: source.studysetId, studysetId: source.studysetId,
child: _Reader(state: state), child: _StudysetLanguages(
studysetId: source.studysetId,
child: _Reader(state: state),
),
), ),
PdfReaderStateLoading() => const Scaffold( PdfReaderStateLoading() => const Scaffold(
body: Center(child: AppLoadingIndicator()), body: Center(child: AppLoadingIndicator()),

Some files were not shown because too many files have changed in this diff Show more