feat(todo,app): files attached to tasks and comments

Halkyon's ADR 0043, D1, D3, D5 and D8: any file up to 100 MB, attached to
a task's description or to a comment, sealed as everything of Todo's.

Server: the attachment table, synced and sealed as the others, with the
sealed file's hash readable for the store; todoFiles hands halkyon_base
its rows, readable by their space's members, and takes sealed files
alone; the files no attachment names go daily. Its tests hold downloads
to the row's space, refuse a readable file and prune what no live row
names. Migration attachments: CREATE TABLE "attachment" with its indexes
and the foreign key to its space, cascading as every synced table's.

App: AttachmentRepository seals each file for its row, keeps it sealed
and opened in the person's folder, uploads it after each sync and fetches
it as it shows, both held back by Without files on mobile data but for a
file tapped. Under the description and a comment's text, a row of them:
pictures as thumbnails opening full-size, other files as chips opening in
the system's app, else saved; the paperclip beside the description and in the comment's
composer adds them, the composer holding them until it is sent. A task deleted takes its files
and its comments' along, a comment its own.

halkyon_app opens a file in the system's app (FileOpener): on Android
through its plugin and a FileProvider of its own class, sharing the
cache's halkyon_opened/ folder alone; on the desktop as a file: URI.

Whats-New: Attach files to a task's description or a comment with the paperclip: pictures show as thumbnails, other files open in their app.
Whats-New-De: Hänge mit der Büroklammer Dateien an die Beschreibung einer Aufgabe oder an einen Kommentar: Bilder erscheinen als Vorschau, andere Dateien öffnen sich in ihrer App.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 01:17:22 +01:00
commit 3ca4b4308d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 23300 additions and 62 deletions

View file

@ -0,0 +1,278 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_todo_server/src/attachment/todo_files.dart';
import 'package:halkyon_todo_server/src/generated/protocol.dart';
import 'package:halkyon_todo_server/src/todo_base.dart';
import 'package:serverpod/protocol.dart' show CloudStorageEntry;
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:test/test.dart';
import '../support/todo_fixtures.dart';
import '../test_tools/serverpod_test_tools.dart';
/// Todo's files (Halkyon's ADR 0043): the module's store, which its own
/// tests hold to its rules, with Todo's attachment rows naming the files.
/// These call the module as its `files` endpoint does, on a signed-in
/// session.
void main() {
withServerpod(
'Attached files',
(sessionBuilder, endpoints) {
late Session session;
late UuidValue alice;
late UuidValue bob;
/// A sealed file, as the app seals every file: `HLKF`, version 1, and
/// what can't be looked into.
final sealed = [...ascii.encode('HLKF'), 1, 4, 2];
final sealedHash = sha256.convert(sealed).toString();
/// The spaces a test made, which it deletes again with their files.
final spaces = <UuidValue>{};
setUp(() {
// The test pod is built without the generated Serverpod class, which
// sets up sync for the running server.
Serverpod.instance.initializeOfflineSync(syncTables: syncTables);
useTodoBase(sessionBuilder);
session = sessionBuilder.build();
alice = const Uuid().v7obj();
bob = const Uuid().v7obj();
spaces.addAll([alice, bob]);
});
tearDown(() async {
for (final space in spaces) {
await session.db.unsafeExecute(
'DELETE FROM "serverpod_cloud_storage" WHERE "path" LIKE @prefix',
parameters: QueryParameters.named({
'prefix': '${storedFilePath(space, '')}%',
}),
);
await session.db.unsafeExecute(
'DELETE FROM "offline_sync_spaces" WHERE "uuidSpaceId" = @space',
parameters: QueryParameters.named({'space': space.uuid}),
);
}
spaces.clear();
});
/// [person]'s session, `identified`.
Session signedIn(UuidValue person) => sessionBuilder
.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
person.uuid,
{identified},
),
)
.build();
/// Adds [bytes] as [person], as the app does.
Future<bool> add(
UuidValue person,
List<int> bytes, {
required bool encrypted,
}) async {
final hash = sha256.convert(bytes).toString();
final description = await startFileUpload(
signedIn(person),
hash,
bytes.length,
encrypted: encrypted,
);
if (description != null) {
await _upload(session, description, bytes);
}
return await finishFileUpload(
signedIn(person),
hash,
encrypted: encrypted,
);
}
final notFound = throwsA(isA<StoredFileNotFoundException>());
test('Todo keeps files, in its own store', () {
expect(todoBase().files, same(todoFiles));
});
test('keeps sealed files alone', () async {
expect(
await add(alice, utf8.encode('a photo'), encrypted: false),
isFalse,
);
expect(await add(alice, sealed, encrypted: true), isTrue);
});
test("serves a file by its attachment to its space's owner, and nobody "
'else', () async {
final attachment = await _attachmentIn(session, alice, sealedHash);
await add(alice, sealed, encrypted: true);
expect(
Uint8List.sublistView(
await downloadFile(signedIn(alice), attachment),
),
sealed,
);
await expectLater(downloadFile(signedIn(bob), attachment), notFound);
await expectLater(
downloadFile(signedIn(alice), const Uuid().v7obj()),
notFound,
);
});
test("serves a shared space's files to its members, and only them "
'(ADR 0044)', () async {
final carol = const Uuid().v7obj();
spaces.add(carol);
final syncSpaces = OfflineSyncSpaces(session);
final shared = await syncSpaces.createFor(carol);
spaces.add(shared);
await syncSpaces.grant(
user: alice,
space: shared,
role: OfflineSyncSpaceRole.readOnly,
);
final attachment = await _attachmentIn(
session,
carol,
sealedHash,
space: shared,
);
await session.storage.storeFile(
storageId: fileStorage,
path: storedFilePath(shared, sealedHash),
byteData: ByteData.sublistView(Uint8List.fromList(sealed)),
);
expect(
Uint8List.sublistView(
await downloadFile(signedIn(alice), attachment),
),
sealed,
);
await expectLater(downloadFile(signedIn(bob), attachment), notFound);
});
test('serves no deleted attachment', () async {
final attachment = await _attachmentIn(session, alice, sealedHash);
await add(alice, sealed, encrypted: true);
await _deleteAttachment(session, alice, attachment);
await expectLater(downloadFile(signedIn(alice), attachment), notFound);
});
test('counts and keeps the files live attachments name; prunes the '
'rest', () async {
final other = [...ascii.encode('HLKF'), 1, 7, 7];
final otherHash = sha256.convert(other).toString();
await add(alice, sealed, encrypted: true);
await add(alice, other, encrypted: true);
final kept = await _attachmentIn(session, alice, sealedHash);
final gone = await _attachmentIn(session, alice, otherHash);
await _deleteAttachment(session, alice, gone);
final longAgo = DateTime.now().toUtc().subtract(
const Duration(days: 8),
);
for (final hash in [sealedHash, otherHash]) {
await _claimed(session, alice, hash, longAgo);
}
expect((await fileUsageOf(signedIn(alice))).usedBytes, sealed.length);
expect(await pruneUnreferencedFiles(session, now: DateTime.now()), 1);
expect(
Uint8List.sublistView(await downloadFile(signedIn(alice), kept)),
sealed,
);
});
},
// The sync engine's database, as the running server has it. Writing
// through it takes transactions of its own, outside the one a test would
// be rolled back in, so the tests delete what they made instead.
databaseInterceptor: (session, inner) {
final database = offlineSyncDatabaseInterceptor(session, inner);
if (database is OfflineSyncDatabase) {
_syncDatabases[session] = database;
}
return database;
},
rollbackDatabase: RollbackDatabase.disabled,
);
}
/// The sync engine's database of each session, as the test pod's own
/// database wraps it.
final _syncDatabases = Expando<OfflineSyncDatabase>();
/// An attachment naming the file with [hash] in [person]'s personal space,
/// or in [space], written as their device would through the sync engine
/// (the payload stands in for a sealed one: the server never reads it).
Future<UuidValue> _attachmentIn(
Session session,
UuidValue person,
String hash, {
UuidValue? space,
}) => _syncDatabases[session]!.transactionForUser(
person,
spaceId: space,
(transaction) async => (await Attachment.db.insertRow(
session,
Attachment(sha256: hash, keyId: 'todo1.0.key', payload: ByteData(3)),
transaction: transaction,
)).id!,
);
/// Deletes the attachment [attachment] as [person] would through the sync
/// engine.
Future<void> _deleteAttachment(
Session session,
UuidValue person,
UuidValue attachment,
) => _syncDatabases[session]!.transactionForUser(
person,
(transaction) => Attachment.db.deleteWhere(
session,
where: (t) => t.id.equals(attachment),
transaction: transaction,
),
);
/// Uploads [bytes] as the upload [description] says, standing in for the
/// app's HTTP upload.
Future<void> _upload(
Session session,
String description,
List<int> bytes,
) async {
final url = Uri.parse(
(jsonDecode(description) as Map<String, Object?>)['url']! as String,
);
final storage =
Serverpod.instance.storage[url.queryParameters['storage']!]!
as DatabaseCloudStorage;
await storage.storeUnverifiedFile(
session: session,
path: url.queryParameters['path']!,
byteData: ByteData.sublistView(Uint8List.fromList(bytes)),
);
}
/// Makes the stored file with [hash] of [space] last claimed [at].
Future<void> _claimed(
Session session,
UuidValue space,
String hash,
DateTime at,
) => CloudStorageEntry.db.updateWhere(
session,
columnValues: (t) => [t.addedTime(at)],
where: (t) =>
t.storageId.equals(fileStorage) &
t.path.equals(storedFilePath(space, hash)),
);

View file

@ -53,7 +53,7 @@ void main() {
test(
'deleting a person deletes their space with every key, project, '
"section, task, label, filter, comment and settings in it, and nobody else's",
"section, task, label, filter, comment, settings and attachment in it, and nobody else's",
() async {
for (final person in [alice, bob]) {
await _projectOf(session, person);
@ -109,6 +109,7 @@ const _tables = [
'saved_filter',
'task_comment',
'todo_settings',
'attachment',
];
/// A key, a project, a section, a task, a label, a filter and the settings in
@ -158,4 +159,14 @@ Future<void> _projectOf(Session session, UuidValue person) =>
TaskComment(keyId: 'todo1.0.key', payload: sealed, nonce: sealed),
transaction: transaction,
);
await Attachment.db.insertRow(
session,
Attachment(
sha256: '0' * 64,
keyId: 'todo1.0.key',
payload: sealed,
nonce: sealed,
),
transaction: transaction,
);
});

View file

@ -184,6 +184,8 @@ class _InternalTestEndpoints extends TestEndpoints
}
class _FutureCalls {
late final filePruning = _FilePruningFutureCall();
late final pruning = _PruningFutureCall();
}
@ -372,6 +374,21 @@ class _WeatherEndpoint {
}
}
class _FilePruningFutureCall {
Future<void> pruneFiles(_ist.TestSessionBuilder sessionBuilder) async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild();
try {
await _i772x17y.FilePruningPruneFilesFutureCall().invoke(
_localUniqueSession,
null,
);
} finally {
await _localUniqueSession.close();
}
}
}
class _PruningFutureCall {
Future<void> pruneAccounts(_ist.TestSessionBuilder sessionBuilder) async {
var _localUniqueSession =