fix(feedback): a suggestion takes along only its own tree's sealed files (ADR 0019)
The card's, note's or step's own id goes with a suggestion, and a sealed file goes along readable only where it is under the key of that item's tree: a file of another private tree, named by a row the instance planted, is left out and never uploaded readable (the short reading of 22 Oct, finding 12). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
ba7067a861
commit
5a61f2ea07
7 changed files with 148 additions and 38 deletions
|
|
@ -229,6 +229,11 @@ class AssetRepository(
|
|||
@override
|
||||
Future<bool> exists(String id) async => await _rows.byId(id) != null;
|
||||
|
||||
/// The key of the tree the item [id] of [kind] is in; null while
|
||||
/// readable.
|
||||
Future<String?> keyOfItem(ItemKind kind, String id) =>
|
||||
_sealing.keyOfItem(kind, id);
|
||||
|
||||
@override
|
||||
Future<({Uint8List bytes, String fileName, String mimeType})?> plainOf(
|
||||
String id,
|
||||
|
|
|
|||
|
|
@ -284,6 +284,7 @@ class _CardComposerScreenState() extends State<CardComposerScreen> {
|
|||
feedback.suggestCard(
|
||||
selected.content.normalized,
|
||||
body: body,
|
||||
itemId: selected.cardId,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
|
|
|
|||
|
|
@ -68,48 +68,72 @@ class FeedbackRepository(
|
|||
/// Suggests a copy's card, as the person has it ([content]), to the
|
||||
/// author of the published studyset it came from, with [body] saying
|
||||
/// why. Throws an [ArgumentError] for a card of the person's own.
|
||||
Future<void> suggestCard(CardContent content, {required String body}) =>
|
||||
_suggest(
|
||||
content.published,
|
||||
FeedbackTargetKind.card,
|
||||
_json(
|
||||
encodeCardPayload(
|
||||
content
|
||||
.withField(CardField.published, null)
|
||||
.withField(CardField.report, null),
|
||||
),
|
||||
),
|
||||
body,
|
||||
);
|
||||
///
|
||||
/// [itemId], the card's own id here, lets its files go along, those of
|
||||
/// its own tree alone (the short reading of 22 Oct).
|
||||
Future<void> suggestCard(
|
||||
CardContent content, {
|
||||
required String body,
|
||||
String? itemId,
|
||||
}) => _suggest(
|
||||
content.published,
|
||||
FeedbackTargetKind.card,
|
||||
_json(
|
||||
encodeCardPayload(
|
||||
content
|
||||
.withField(CardField.published, null)
|
||||
.withField(CardField.report, null),
|
||||
),
|
||||
),
|
||||
body,
|
||||
itemId: itemId,
|
||||
);
|
||||
|
||||
/// Suggests a copy's note, as with [suggestCard].
|
||||
Future<void> suggestNote(NoteContent content, {required String body}) =>
|
||||
_suggest(
|
||||
content.published,
|
||||
FeedbackTargetKind.note,
|
||||
_json(
|
||||
encodeNotePayload(content.withField(NoteField.published, null)),
|
||||
),
|
||||
body,
|
||||
);
|
||||
Future<void> suggestNote(
|
||||
NoteContent content, {
|
||||
required String body,
|
||||
String? itemId,
|
||||
}) => _suggest(
|
||||
content.published,
|
||||
FeedbackTargetKind.note,
|
||||
_json(
|
||||
encodeNotePayload(content.withField(NoteField.published, null)),
|
||||
),
|
||||
body,
|
||||
itemId: itemId,
|
||||
);
|
||||
|
||||
/// Suggests a copy's step, as with [suggestCard].
|
||||
Future<void> suggestStep(StepContent content, {required String body}) =>
|
||||
_suggest(
|
||||
content.published,
|
||||
FeedbackTargetKind.step,
|
||||
_json(
|
||||
encodeStepPayload(content.withField(StepField.published, null)),
|
||||
),
|
||||
body,
|
||||
);
|
||||
Future<void> suggestStep(
|
||||
StepContent content, {
|
||||
required String body,
|
||||
String? itemId,
|
||||
}) => _suggest(
|
||||
content.published,
|
||||
FeedbackTargetKind.step,
|
||||
_json(
|
||||
encodeStepPayload(content.withField(StepField.published, null)),
|
||||
),
|
||||
body,
|
||||
itemId: itemId,
|
||||
);
|
||||
|
||||
Future<void> _suggest(
|
||||
PublishedRef? origin,
|
||||
FeedbackTargetKind kind,
|
||||
Map<String, Object?> proposed,
|
||||
String body,
|
||||
) async {
|
||||
String body, {
|
||||
String? itemId,
|
||||
}) async {
|
||||
// The key of the item's own tree: only files under it go along readable.
|
||||
final itemKey = itemId == null
|
||||
? null
|
||||
: await _assets.keyOfItem(switch (kind) {
|
||||
FeedbackTargetKind.card => ItemKind.card,
|
||||
FeedbackTargetKind.note => ItemKind.note,
|
||||
_ => ItemKind.step,
|
||||
}, itemId);
|
||||
if (origin == null) {
|
||||
throw ArgumentError.value(origin, 'origin', 'is not a copy');
|
||||
}
|
||||
|
|
@ -117,11 +141,20 @@ class FeedbackRepository(
|
|||
final files = <FeedbackFile>[];
|
||||
for (final assetId in assetIdsIn(proposed)) {
|
||||
if (await _assets.fileOf(assetId) case final file?) {
|
||||
// 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) {
|
||||
continue;
|
||||
// A sealed file goes along readable only where it is of the item's
|
||||
// own tree: the instance could have named another private tree's
|
||||
// file through a row it planted (the short reading of 22 Oct).
|
||||
final fileKey = await _assets.keyIdOf(assetId);
|
||||
if (fileKey != null) {
|
||||
if (fileKey != itemKey) {
|
||||
continue;
|
||||
}
|
||||
await _assets.add(
|
||||
file.bytes,
|
||||
fileName: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
studysetId: null,
|
||||
);
|
||||
}
|
||||
files.add(
|
||||
FeedbackFile(
|
||||
|
|
|
|||
|
|
@ -80,6 +80,9 @@ class _StepEditorScreenState() extends State<StepEditorScreen> {
|
|||
send: (feedback, body) => feedback.suggestStep(
|
||||
state.content.normalized,
|
||||
body: body,
|
||||
itemId: GoRouterState.of(
|
||||
context,
|
||||
).pathParameters['stepId'],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ class _NoteEditorScreenState() extends State<NoteEditorScreen> {
|
|||
send: (feedback, body) => feedback.suggestNote(
|
||||
state.content.normalized,
|
||||
body: body,
|
||||
itemId: state.noteId,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -496,6 +496,7 @@ class _CardsPanelState()
|
|||
send: (feedback, body) => feedback.suggestCard(
|
||||
draft.content.normalized,
|
||||
body: body,
|
||||
itemId: draft.cardId,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
|
|
|
|||
|
|
@ -129,6 +129,72 @@ void main() {
|
|||
]);
|
||||
});
|
||||
|
||||
test("a suggestion takes along readable only files of the item's own "
|
||||
'tree: one of another private tree, named by a row the instance '
|
||||
'planted, is never uploaded', () async {
|
||||
final rows = FakeAssetGateway();
|
||||
final cache = MemoryAssetFileCache();
|
||||
final sealedAssets = AssetRepository(
|
||||
rows,
|
||||
FakeAssetServerGateway(),
|
||||
cache,
|
||||
FakeAssetSealing(
|
||||
keys: {'own-tree': 'k-own', 'other-tree': 'k-other'},
|
||||
itemKeys: {'card-1': 'k-own'},
|
||||
),
|
||||
);
|
||||
final sealedFeedback = FeedbackRepository(
|
||||
written,
|
||||
server,
|
||||
cards,
|
||||
notes,
|
||||
LearningPathRepository(FakeLearningPathGateway()),
|
||||
sealedAssets,
|
||||
);
|
||||
final mine = await sealedAssets.add(
|
||||
image,
|
||||
fileName: 'cat.png',
|
||||
mimeType: 'image/png',
|
||||
studysetId: 'own-tree',
|
||||
);
|
||||
final secret = utf8.encode('a secret picture');
|
||||
final theirs = await sealedAssets.add(
|
||||
secret,
|
||||
fileName: 'secret.png',
|
||||
mimeType: 'image/png',
|
||||
studysetId: 'other-tree',
|
||||
);
|
||||
Map<String, Object?> imageBlock(String id) => {
|
||||
'type': 'image',
|
||||
'data': {'url': 'asset:$id'},
|
||||
};
|
||||
|
||||
await sealedFeedback.suggestCard(
|
||||
CardContent(
|
||||
front: RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [imageBlock(mine), imageBlock(theirs)],
|
||||
},
|
||||
}),
|
||||
back: RichContent.plain('a cat'),
|
||||
published: origin,
|
||||
),
|
||||
body: 'Pictures help.',
|
||||
itemId: 'card-1',
|
||||
);
|
||||
|
||||
final files = written.threads.single.json['files']! as List<Object?>;
|
||||
expect([for (final file in files) (file! as Map)['assetId']], [mine]);
|
||||
// A readable copy of the own file waits to upload; the other's never.
|
||||
final readable = {
|
||||
for (final row in rows.rows)
|
||||
if (row.keyId == null) row.sha256,
|
||||
};
|
||||
expect(readable, {imageHash});
|
||||
expect(readable, isNot(contains(sha256Of(secret))));
|
||||
});
|
||||
|
||||
test("an accepted suggestion becomes the author's own card, its files "
|
||||
'theirs, and is recorded so', () async {
|
||||
final id = await cards.create(
|
||||
|
|
|
|||
Loading…
Reference in a new issue