fix(privacy): publishing and suggestions after the short reading (ADR 0019)
Publishing is refused on the device where anything in the tree is sealed, before any tag's name goes out: the tree and the tags' links are the instance's to change. A suggestion leaves sealed files out, as the instance could name another private tree's file through a planted row. Docs: ADR 0019's "The short reading (22 Oct 2026)", and the general residual risk that any device can be held at an earlier genuine record; Learn's AGENTS.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
0ecf5f1448
commit
2d772a17e2
3 changed files with 125 additions and 17 deletions
|
|
@ -4,7 +4,7 @@ import 'dart:io';
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart'
|
||||
show Client, UuidValue;
|
||||
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';
|
||||
|
|
@ -453,8 +453,7 @@ Future<PersonDependencies> _openPersonData(
|
|||
// wraps them), not the key rows the instance hands over (the short
|
||||
// reading of 22 Oct); an anonymous person's are this device's own.
|
||||
registerHasKeys: person.isIdentified
|
||||
? () async =>
|
||||
(await client.modules.base.account.signInWays()).password
|
||||
? () async => (await client.modules.base.account.signInWays()).password
|
||||
: () async => null,
|
||||
);
|
||||
final privacy = Privacy(db, contentKeys)..requestSync = sync.requestSync;
|
||||
|
|
@ -535,6 +534,12 @@ Future<PersonDependencies> _openPersonData(
|
|||
FilePublishedStore(File(p.join(folder.path, 'published.json'))),
|
||||
tagNames: (studysetId) async {
|
||||
final tree = subtreeOf(studysetId, await studysets.withDeleted());
|
||||
// Refused here, before any tag's name goes out, where anything in it
|
||||
// is private: the tree and the tags' links are the instance's to
|
||||
// change (the short reading of 22 Oct).
|
||||
if (await privacy.holdsSealed(tree.toSet())) {
|
||||
throw PublishRefusedException(reason: PublishRefusal.private);
|
||||
}
|
||||
final tagging = await tags.read();
|
||||
final carried = <String>{
|
||||
for (final card in await cards.cardsIn(tree))
|
||||
|
|
|
|||
|
|
@ -508,6 +508,74 @@ class Privacy(
|
|||
return of;
|
||||
}
|
||||
|
||||
/// Whether anything in the studysets [ids] is sealed: a studyset, a card's
|
||||
/// or a note's revision, a path, a step's revision or a PDF. Publishing
|
||||
/// refuses on the device then, before any tag's name goes out (the short
|
||||
/// reading of 22 Oct).
|
||||
Future<bool> holdsSealed(Set<String> ids) async {
|
||||
final uuids = {for (final id in ids) _uuid(id)};
|
||||
if (await protocol.Studyset.db.count(
|
||||
_db,
|
||||
where: (t) => t.id.inSet(uuids) & t.keyId.notEquals(null),
|
||||
) >
|
||||
0) {
|
||||
return true;
|
||||
}
|
||||
final cards = {
|
||||
for (final card in await protocol.Card.db.find(
|
||||
_db,
|
||||
where: (t) => t.studysetId.inSet(uuids),
|
||||
))
|
||||
card.id!,
|
||||
};
|
||||
final notes = {
|
||||
for (final note in await protocol.Note.db.find(
|
||||
_db,
|
||||
where: (t) => t.studysetId.inSet(uuids),
|
||||
))
|
||||
note.id!,
|
||||
};
|
||||
final paths = {
|
||||
for (final path in await protocol.LearningPath.db.find(
|
||||
_db,
|
||||
where: (t) => t.studysetId.inSet(uuids),
|
||||
))
|
||||
path.id!,
|
||||
};
|
||||
final steps = {
|
||||
for (final step in await protocol.LearningPathStep.db.find(
|
||||
_db,
|
||||
where: (t) => t.pathId.inSet(paths),
|
||||
))
|
||||
step.id!,
|
||||
};
|
||||
return await protocol.CardRevision.db.count(
|
||||
_db,
|
||||
where: (t) => t.cardId.inSet(cards) & t.keyId.notEquals(null),
|
||||
) >
|
||||
0 ||
|
||||
await protocol.NoteRevision.db.count(
|
||||
_db,
|
||||
where: (t) => t.noteId.inSet(notes) & t.keyId.notEquals(null),
|
||||
) >
|
||||
0 ||
|
||||
await protocol.LearningPath.db.count(
|
||||
_db,
|
||||
where: (t) => t.id.inSet(paths) & t.keyId.notEquals(null),
|
||||
) >
|
||||
0 ||
|
||||
await protocol.LearningPathStepRevision.db.count(
|
||||
_db,
|
||||
where: (t) => t.stepId.inSet(steps) & t.keyId.notEquals(null),
|
||||
) >
|
||||
0 ||
|
||||
await protocol.PdfSource.db.count(
|
||||
_db,
|
||||
where: (t) => t.studysetId.inSet(uuids) & t.keyId.notEquals(null),
|
||||
) >
|
||||
0;
|
||||
}
|
||||
|
||||
/// The ids of [studysetId] and every studyset inside it.
|
||||
Future<Set<String>> treeOf(String studysetId) async {
|
||||
final children = <String, List<String>>{};
|
||||
|
|
|
|||
|
|
@ -117,16 +117,11 @@ class FeedbackRepository(
|
|||
final files = <FeedbackFile>[];
|
||||
for (final assetId in assetIdsIn(proposed)) {
|
||||
if (await _assets.fileOf(assetId) case final file?) {
|
||||
// A sealed file (a private copy's) is uploaded readable too, by the
|
||||
// hash the author asks for it with: a suggestion is for its author
|
||||
// to read (the reading of 22 Oct).
|
||||
// A sealed file is left out: the instance could have named another
|
||||
// private tree's file through a row it planted, and the suggestion
|
||||
// would upload it readable (the short reading of 22 Oct).
|
||||
if (await _assets.keyIdOf(assetId) != null) {
|
||||
await _assets.add(
|
||||
file.bytes,
|
||||
fileName: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
studysetId: null,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
files.add(
|
||||
FeedbackFile(
|
||||
|
|
|
|||
Loading…
Reference in a new issue