refactor(base,learn): people's files kept by halkyon_base

ADR 0043, D2, D4, D6 and D7, the first step towards Todo's attachments:
Learn's file store moves into the module, for every server that keeps
files, and Learn becomes its first host.

halkyon_base gets the store as Learn had it: uploads into the caller's
own space by SHA-256, downloads by a row the caller may read, each
person's room (FILE_LIMIT_IDENTIFIED_MB, FILE_LIMIT_INSTANCE_MB, the
operator's file_allowance, which moves into the module unchanged) and
the pruning of files no live row names. It stays free of the sync
engine: a host hands it a FileStore saying which of its rows name a file
and what it takes (HalkyonBase.files); without one the files endpoint
refuses. Its tests, with refusals, run on a stand-in host.

Learn hands it learnFiles: its asset rows, read by their space's members,
a readable file over 20 MB only as a PDF, up to 100 MB. Its app calls the
module; its own asset endpoint stays for released apps alone, forwarding
to the module in Learn's own models, whose names are on the wire. Its
file tests still pass through it, end to end.

Migrations: the module's creates file_allowance. Learn's server migration
holds version rows alone, as tables are matched by name (ADR 0014); its
device migration creates the module's empty table, as every module's.
Todo's, Train's and Meditate's create the empty table.

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 00:11:36 +01:00
commit b7f0aaaec7
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
65 changed files with 112084 additions and 348 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,71 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "file_allowance",
"dartName": "FileAllowance",
"module": "halkyon_base",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 6,
"isNullable": false,
"columnDefault": "serial",
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "authUserId",
"fieldName": "authUserId",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "maxBytes",
"fieldName": "maxBytes",
"columnType": 6,
"isNullable": false,
"dartType": "int"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "note",
"fieldName": "note",
"columnType": 0,
"isNullable": true,
"dartType": "String?"
}
],
"foreignKeys": [],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "file_allowance_person_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "authUserId"
}
],
"type": "btree",
"isUnique": true,
"isPrimary": false
}
],
"managed": true
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,6 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -34,6 +34,8 @@ part '20261025135744595-waiting-accounts/migration.dart';
part '20261025165102861-assistants/migration.dart';
part '20261025184004860-assistant-conversations/migration.dart';
part '20261026140329810-assistant-budgets/migration.dart';
part '20261110225336783-file-store-in-halkyon-base/migration.dart';
part '20261110225358224-file-store-in-halkyon-base/migration.dart';
/// Migration registry for the client-side database.
class MigrationRegistry {
@ -70,6 +72,8 @@ class MigrationRegistry {
_Migration20261025165102861_assistants(),
_Migration20261025184004860_assistant_conversations(),
_Migration20261026140329810_assistant_budgets(),
_Migration20261110225336783_file_store_in_halkyon_base(),
_Migration20261110225358224_file_store_in_halkyon_base(),
];
/// List of all client-side database migration versions.

View file

@ -1,128 +0,0 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// More room for one person's files than the instance's default (decided
/// 2026-10-02): the operator grants it on request, by adding a row. Only the
/// server reads it; deleting the person deletes it.
abstract class FileAllowance
implements _isc.SerializableModel, _isc.ProtocolSerialization {
FileAllowance._({
this.id,
required this.authUserId,
required this.maxBytes,
this.note,
});
factory FileAllowance({
int? id,
required _isc.UuidValue authUserId,
required int maxBytes,
String? note,
}) = _FileAllowanceImpl;
factory FileAllowance.fromJson(Map<String, dynamic> jsonSerialization) {
return FileAllowance(
id: jsonSerialization['id'] as int?,
authUserId: _isc.UuidValueJsonExtension.fromJson(
jsonSerialization['authUserId'],
),
maxBytes: jsonSerialization['maxBytes'] as int,
note: jsonSerialization['note'] as String?,
);
}
/// The database id, set if the object has been inserted into the
/// database or if it has been fetched from the database. Otherwise,
/// the id will be null.
int? id;
/// The person: the AuthUser's id.
_isc.UuidValue authUserId;
/// How many bytes of files they may keep.
int maxBytes;
/// Why, for the operator (e.g. "asked by mail, 2026-10-02").
String? note;
/// Returns a shallow copy of this [FileAllowance]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
FileAllowance copyWith({
int? id,
_isc.UuidValue? authUserId,
int? maxBytes,
String? note,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'FileAllowance',
if (id != null) 'id': id,
'authUserId': authUserId.toJson(),
'maxBytes': maxBytes,
if (note != null) 'note': note,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'FileAllowance',
if (id != null) 'id': id,
'authUserId': authUserId.toJson(),
'maxBytes': maxBytes,
if (note != null) 'note': note,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _Undefined {}
class _FileAllowanceImpl extends FileAllowance {
_FileAllowanceImpl({
int? id,
required _isc.UuidValue authUserId,
required int maxBytes,
String? note,
}) : super._(
id: id,
authUserId: authUserId,
maxBytes: maxBytes,
note: note,
);
/// Returns a shallow copy of this [FileAllowance]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
FileAllowance copyWith({
Object? id = _Undefined,
_isc.UuidValue? authUserId,
int? maxBytes,
Object? note = _Undefined,
}) {
return FileAllowance(
id: id is int? ? id : this.id,
authUserId: authUserId ?? this.authUserId,
maxBytes: maxBytes ?? this.maxBytes,
note: note is String? ? note : this.note,
);
}
}

View file

@ -65,16 +65,12 @@ import 'protocol.dart' as _il2as5qe;
import 'sync_tables.dart' as _ii3f3u05;
import 'package:halkyon_learn_client/migrations/migration_registry.dart';
/// The files of people's content (ADR 0004, rule 5): images, sounds and
/// PDFs, stored in the server's private storage at a path derived from the space
/// and the file's SHA-256, so a file added twice is stored once.
///
/// Uploading goes into the caller's own space, by the file's hash, before or
/// after its asset row syncs: the server asks for the file only when it
/// lacks it, and keeps it only when its content has that hash. Downloading
/// goes by the asset row, from a space the caller may read; any other asks
/// as if the file weren't there. Files no asset row refers to any more are
/// pruned ([pruneUnreferencedFiles]).
/// The files of people's content (ADR 0004, rule 5), kept by halkyon_base
/// since Halkyon's ADR 0043 (its `files` endpoint, Learn's rows named by
/// `learnFiles`). This endpoint stays for Learn's released apps alone (D7):
/// each call is the module's, its refusals and room in Learn's own models,
/// whose names are on the wire. Dropped once `minimumAppBuild` passes the
/// first build calling the module.
/// {@category Endpoint}
class EndpointAsset extends _isc.EndpointRef {
EndpointAsset(_isc.EndpointCaller caller) : super(caller);
@ -82,11 +78,9 @@ class EndpointAsset extends _isc.EndpointRef {
@override
String get name => 'asset';
/// How to upload the caller's file with [sha256] (lowercase hex) of
/// [byteSize] bytes, [encrypted] if it is a private studyset's (ADR
/// 0019), which may be larger by [encryptionOverhead]; null when the
/// server has it already. Throws a [FileLimitExceededException] when it
/// doesn't fit (see [FileLimits]).
/// How to upload the caller's file with [sha256] of [byteSize] bytes,
/// [encrypted] where a private studyset's; null when the server has it
/// already. Throws a [FileLimitExceededException] when it doesn't fit.
_ida.Future<String?> startUpload(
String sha256,
int byteSize, {
@ -102,12 +96,7 @@ class EndpointAsset extends _isc.EndpointRef {
);
/// Completes the upload of the caller's file with [sha256]; whether it is
/// stored now. A file whose content has another hash is deleted, and so is
/// a readable one over [maxOtherBytes] that isn't a PDF, and one that no
/// longer fits, with a [FileLimitExceededException]. An [encrypted] file
/// (ADR 0019) can't be looked into: it must start as a sealed file does
/// (`HLKF`, version 1), or it is deleted, and its size alone counts, as
/// the quotas' sizes do.
/// stored now.
_ida.Future<bool> finishUpload(
String sha256, {
bool? encrypted,

View file

@ -40,7 +40,6 @@ import 'package:serverpod_database/serverpod_database.dart' as _isd;
import 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart'
as _ipulbpi2;
import 'asset/asset.dart' as _ipb7vkxr;
import 'asset/file_allowance.dart' as _i9uv0f1n;
import 'asset/file_limit_exceeded_exception.dart' as _it5d94hq;
import 'asset/file_usage.dart' as _iurwo9k8;
import 'assistant/assistant_conversation.dart' as _ikd6xdrt;
@ -101,7 +100,6 @@ import 'tag/learning_path_tag.dart' as _i31v2f8r;
import 'tag/note_tag.dart' as _itkfn57x;
import 'tag/tag.dart' as _ie8t3ar4;
export 'asset/asset.dart';
export 'asset/file_allowance.dart';
export 'asset/file_limit_exceeded_exception.dart';
export 'asset/file_usage.dart';
export 'assistant/assistant_conversation.dart';
@ -2575,9 +2573,6 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _ipb7vkxr.Asset) {
return _ipb7vkxr.Asset.fromJson(data) as T;
}
if (t == _i9uv0f1n.FileAllowance) {
return _i9uv0f1n.FileAllowance.fromJson(data) as T;
}
if (t == _it5d94hq.FileLimitExceededException) {
return _it5d94hq.FileLimitExceededException.fromJson(data) as T;
}
@ -2758,10 +2753,6 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _isc.getType<_ipb7vkxr.Asset?>()) {
return (data != null ? _ipb7vkxr.Asset.fromJson(data) : null) as T;
}
if (t == _isc.getType<_i9uv0f1n.FileAllowance?>()) {
return (data != null ? _i9uv0f1n.FileAllowance.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_it5d94hq.FileLimitExceededException?>()) {
return (data != null
? _it5d94hq.FileLimitExceededException.fromJson(data)
@ -3136,7 +3127,6 @@ class Protocol extends _isd.DatabaseSerializationManager {
static String? getClassNameForType(Type type) {
return switch (type) {
_ipb7vkxr.Asset => 'Asset',
_i9uv0f1n.FileAllowance => 'FileAllowance',
_it5d94hq.FileLimitExceededException => 'FileLimitExceededException',
_iurwo9k8.FileUsage => 'FileUsage',
_ikd6xdrt.AssistantConversation => 'AssistantConversation',
@ -3215,8 +3205,6 @@ class Protocol extends _isd.DatabaseSerializationManager {
switch (data) {
case _ipb7vkxr.Asset():
return 'Asset';
case _i9uv0f1n.FileAllowance():
return 'FileAllowance';
case _it5d94hq.FileLimitExceededException():
return 'FileLimitExceededException';
case _iurwo9k8.FileUsage():
@ -3374,9 +3362,6 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (dataClassName == 'Asset') {
return deserialize<_ipb7vkxr.Asset>(data['data']);
}
if (dataClassName == 'FileAllowance') {
return deserialize<_i9uv0f1n.FileAllowance>(data['data']);
}
if (dataClassName == 'FileLimitExceededException') {
return deserialize<_it5d94hq.FileLimitExceededException>(data['data']);
}

View file

@ -1,10 +1,12 @@
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 the server's asset endpoint, uploading through
/// Serverpod's own upload route.
/// [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
@ -13,7 +15,11 @@ class ServerpodAssetServerGateway(final protocol.Client _client)
int byteSize, {
bool encrypted = false,
}) => _refusing(
() => _client.asset.startUpload(sha256, byteSize, encrypted: encrypted),
() => _client.modules.base.files.startUpload(
sha256,
byteSize,
encrypted: encrypted,
),
);
@override
@ -24,12 +30,15 @@ class ServerpodAssetServerGateway(final protocol.Client _client)
@override
Future<bool> finishUpload(String sha256, {bool encrypted = false}) =>
_refusing(
() => _client.asset.finishUpload(sha256, encrypted: encrypted),
() => _client.modules.base.files.finishUpload(
sha256,
encrypted: encrypted,
),
);
@override
Future<FileRoom> room() async {
final usage = await _client.asset.usage();
final usage = await _client.modules.base.files.usage();
return (usedBytes: usage.usedBytes, limitBytes: usage.limitBytes);
}
@ -37,7 +46,7 @@ class ServerpodAssetServerGateway(final protocol.Client _client)
Future<T> _refusing<T>(Future<T> Function() call) async {
try {
return await call();
} on protocol.FileLimitExceededException catch (error) {
} on base.FileLimitExceededException catch (error) {
throw FileLimitReached(
instanceFull: error.instanceFull,
limitBytes: error.limitBytes,
@ -49,9 +58,11 @@ class ServerpodAssetServerGateway(final protocol.Client _client)
Future<Uint8List?> download(String id) async {
try {
return Uint8List.sublistView(
await _client.asset.download(protocol.UuidValue.withValidation(id)),
await _client.modules.base.files.download(
protocol.UuidValue.withValidation(id),
),
);
} on protocol.NotFoundException {
} on base.StoredFileNotFoundException {
return null;
}
}

View file

@ -1,201 +1,96 @@
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:crypto/crypto.dart';
import 'package:halkyon_learn_server/src/asset/file_limits.dart';
import 'package:halkyon_learn_server/src/asset/file_pruning.dart';
import 'package:halkyon_base_server/base.dart' as base;
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:serverpod/protocol.dart' show CloudStorageEntry;
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
/// The files of people's content (ADR 0004, rule 5): images, sounds and
/// PDFs, stored in the server's private storage at a path derived from the space
/// and the file's SHA-256, so a file added twice is stored once.
///
/// Uploading goes into the caller's own space, by the file's hash, before or
/// after its asset row syncs: the server asks for the file only when it
/// lacks it, and keeps it only when its content has that hash. Downloading
/// goes by the asset row, from a space the caller may read; any other asks
/// as if the file weren't there. Files no asset row refers to any more are
/// pruned ([pruneUnreferencedFiles]).
/// The files of people's content (ADR 0004, rule 5), kept by halkyon_base
/// since Halkyon's ADR 0043 (its `files` endpoint, Learn's rows named by
/// `learnFiles`). This endpoint stays for Learn's released apps alone (D7):
/// each call is the module's, its refusals and room in Learn's own models,
/// whose names are on the wire. Dropped once `minimumAppBuild` passes the
/// first build calling the module.
class AssetEndpoint extends Endpoint {
@override
bool get requireLogin => true;
/// The largest file accepted: a PDF (decided 2026-10-03; 100 MB since
/// 2026-11-09, for the books of the old app's library).
static const maxBytes = 100 * 1024 * 1024;
/// The largest file accepted: a PDF.
static const maxBytes = base.FileStore.defaultMaxBytes;
/// The largest file of any other kind, an image or a sound. A larger one
/// is checked to be a PDF once stored, and deleted otherwise.
static const maxOtherBytes = 20 * 1024 * 1024;
/// What encrypting adds to a file at most (ADR 0019).
static const encryptionOverhead = base.FileStore.encryptionOverhead;
/// What encrypting adds to a file at most (ADR 0019): its key's ID, the
/// nonce, the signature and the cipher's tag, with room to spare.
static const encryptionOverhead = 4 * 1024;
/// How to upload the caller's file with [sha256] (lowercase hex) of
/// [byteSize] bytes, [encrypted] if it is a private studyset's (ADR
/// 0019), which may be larger by [encryptionOverhead]; null when the
/// server has it already. Throws a [FileLimitExceededException] when it
/// doesn't fit (see [FileLimits]).
/// How to upload the caller's file with [sha256] of [byteSize] bytes,
/// [encrypted] where a private studyset's; null when the server has it
/// already. Throws a [FileLimitExceededException] when it doesn't fit.
Future<String?> startUpload(
Session session,
String sha256,
int byteSize, {
bool? encrypted,
}) async {
_checkHash(sha256);
final most = maxBytes + (encrypted ?? false ? encryptionOverhead : 0);
if (byteSize <= 0 || byteSize > most) {
throw ArgumentError.value(byteSize, 'byteSize', 'is out of range');
}
final space = _caller(session);
final path = assetPath(space, sha256);
if (await session.storage.fileExists(storageId: assetStorage, path: path)) {
// The file is claimed anew, so pruning leaves it alone for a while
// even if the row that claims it hasn't synced yet.
// UPDATE "serverpod_cloud_storage" SET "addedTime" = $1
// WHERE "storageId" = 'private' AND "path" = $2
await CloudStorageEntry.db.updateWhere(
session,
columnValues: (t) => [t.addedTime(clock.now().toUtc())],
where: (t) => t.storageId.equals(assetStorage) & t.path.equals(path),
);
return null;
}
checkRoom(await roomOf(session, space), byteSize);
return await session.storage.createUploadDescription(
storageId: assetStorage,
path: path,
options: UploadOptions(contentLength: byteSize, maxFileSize: most),
);
}
}) => _inLearnTerms(
() => base.startFileUpload(
session,
sha256,
byteSize,
encrypted: encrypted ?? false,
),
);
/// Completes the upload of the caller's file with [sha256]; whether it is
/// stored now. A file whose content has another hash is deleted, and so is
/// a readable one over [maxOtherBytes] that isn't a PDF, and one that no
/// longer fits, with a [FileLimitExceededException]. An [encrypted] file
/// (ADR 0019) can't be looked into: it must start as a sealed file does
/// (`HLKF`, version 1), or it is deleted, and its size alone counts, as
/// the quotas' sizes do.
/// stored now.
Future<bool> finishUpload(
Session session,
String sha256, {
bool? encrypted,
}) async {
_checkHash(sha256);
final path = assetPath(_caller(session), sha256);
if (!await session.storage.verifyUpload(
storageId: assetStorage,
path: path,
)) {
return false;
}
final bytes = await session.storage.retrieveFile(
storageId: assetStorage,
path: path,
);
if (_hashOf(bytes) != sha256 ||
(encrypted ?? false
? !_isSealed(bytes)
: bytes.lengthInBytes > maxOtherBytes && !_isPdf(bytes)) ||
bytes.lengthInBytes > maxBytes + encryptionOverhead) {
await session.storage.deleteFile(storageId: assetStorage, path: path);
return false;
}
// Checked again now that it is stored: uploads started side by side each
// fitted on their own.
try {
checkRoom(
await roomOf(session, _caller(session), finishedOnly: true),
0,
);
} on FileLimitExceededException {
await session.storage.deleteFile(storageId: assetStorage, path: path);
rethrow;
}
return true;
}
}) => _inLearnTerms(
() => base.finishFileUpload(
session,
sha256,
encrypted: encrypted ?? false,
),
);
/// How much room the caller's files take, and may take.
Future<FileUsage> usage(Session session) async {
final room = await roomOf(session, _caller(session));
return FileUsage(usedBytes: room.used, limitBytes: effectiveLimit(room));
final usage = await base.fileUsageOf(session);
return FileUsage(
usedBytes: usage.usedBytes,
limitBytes: usage.limitBytes,
);
}
/// The file of the asset [assetId]. Throws a [NotFoundException] when the
/// asset isn't in a space the caller may read, or its file isn't uploaded
/// yet.
Future<ByteData> download(Session session, UuidValue assetId) async {
final asset = await Asset.db.findById(session, assetId);
final spaceId = asset?.spaceId;
final space = spaceId == null
? null
: await OfflineSyncSpace.db.findById(session, spaceId);
final caller = _caller(session);
if (asset == null ||
space == null ||
!(space.uuidSpaceId == caller ||
await OfflineSyncSpaces(
session,
).roleOf(user: caller, space: space.uuidSpaceId) !=
null)) {
try {
return await base.downloadFile(session, assetId);
} on base.StoredFileNotFoundException {
throw NotFoundException(what: 'file');
}
}
/// [call], its refusal of room in Learn's model.
Future<T> _inLearnTerms<T>(Future<T> Function() call) async {
try {
return await session.storage.retrieveFile(
storageId: assetStorage,
path: assetPath(space.uuidSpaceId, asset.sha256),
return await call();
} on base.FileLimitExceededException catch (refused) {
throw FileLimitExceededException(
instanceFull: refused.instanceFull,
limitBytes: refused.limitBytes,
usedBytes: refused.usedBytes,
);
} on CloudStorageFileNotFoundException {
throw NotFoundException(what: 'file');
}
}
}
/// Where the file with [sha256] of the space [space] is stored.
String assetPath(UuidValue space, String sha256) =>
'$assetPathPrefix${space.uuid}/$sha256';
base.storedFilePath(space, sha256);
/// The start of every stored file's path; a space's files are below
/// `<prefix><space>/`.
const assetPathPrefix = 'spaces/';
/// The start of every stored file's path.
const assetPathPrefix = base.storedFilePathPrefix;
/// The storage of every person's files.
const assetStorage = 'private';
UuidValue _caller(Session session) =>
UuidValue.withValidation(session.authenticated!.userIdentifier);
void _checkHash(String sha256) {
if (!RegExp(r'^[0-9a-f]{64}$').hasMatch(sha256)) {
throw ArgumentError.value(sha256, 'sha256', 'is no SHA-256 in hex');
}
}
/// Whether [bytes] are a PDF's: they start as every PDF does.
bool _isPdf(ByteData bytes) {
const signature = '%PDF-';
return bytes.lengthInBytes >= signature.length &&
String.fromCharCodes(
Uint8List.sublistView(bytes, 0, signature.length),
) ==
signature;
}
/// Whether [bytes] are a sealed file's (ADR 0019): they start as the app
/// seals every file, `HLKF` and its format's version, 1. What follows can't
/// be looked into, but a readable file said to be sealed can't skip the
/// PDF check unnoticed.
bool _isSealed(ByteData bytes) {
const magic = 'HLKF';
return bytes.lengthInBytes > magic.length + 1 &&
String.fromCharCodes(Uint8List.sublistView(bytes, 0, magic.length)) ==
magic &&
bytes.getUint8(magic.length) == 1;
}
String _hashOf(ByteData bytes) =>
sha256.convert(Uint8List.sublistView(bytes)).toString();
const assetStorage = base.fileStorage;

View file

@ -1,16 +0,0 @@
### More room for one person's files than the instance's default (decided
### 2026-10-02): the operator grants it on request, by adding a row. Only the
### server reads it; deleting the person deletes it.
class: FileAllowance
table: file_allowance
fields:
### The person: the AuthUser's id.
authUserId: UuidValue
### How many bytes of files they may keep.
maxBytes: int
### Why, for the operator (e.g. "asked by mail, 2026-10-02").
note: String?
indexes:
file_allowance_person_idx:
fields: authUserId
unique: true

View file

@ -1,197 +0,0 @@
import 'dart:io';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_learn_server/src/asset/asset_endpoint.dart';
import 'package:halkyon_learn_server/src/asset/file_pruning.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
/// How much room people's files may take (decided 2026-10-02): a person
/// some, and more by a `FileAllowance` the operator grants; and all of them
/// together no more than the instance has room for. Each comes from the
/// environment, in MiB, with a default: `FILE_LIMIT_IDENTIFIED_MB` (500) and
/// `FILE_LIMIT_INSTANCE_MB` (15000; 0 for none). Someone signed in without
/// `identified`, an anonymous account left from before (ADR 0020, D25),
/// keeps nothing. Every file is in the
/// database and in each dump the server keeps: with three dumps, and a
/// fourth while a deploy writes its own, files take five times their size on
/// the disk.
class FileLimits {
/// Limits of [identifiedBytes] and [instanceBytes] (null for none).
const FileLimits({
required this.identifiedBytes,
required this.instanceBytes,
});
/// The limits the environment sets, else the defaults.
factory FileLimits.fromEnvironment([Map<String, String>? environment]) {
final env = environment ?? Platform.environment;
int mib(String name, int fallback) =>
(int.tryParse(env[name] ?? '') ?? fallback) * _mib;
final instance = mib('FILE_LIMIT_INSTANCE_MB', 15000);
return FileLimits(
identifiedBytes: mib('FILE_LIMIT_IDENTIFIED_MB', 500),
instanceBytes: instance > 0 ? instance : null,
);
}
/// What an identified person may keep without an allowance, in bytes.
final int identifiedBytes;
/// What everyone together may keep, in bytes; null for no limit.
final int? instanceBytes;
}
/// The limits the server works with.
FileLimits get fileLimits => _fileLimits ??= FileLimits.fromEnvironment();
/// Makes [limits] the ones the server works with, for tests; null goes back
/// to the environment's.
set fileLimits(FileLimits? limits) => _fileLimits = limits;
FileLimits? _fileLimits;
const _mib = 1024 * 1024;
/// How much room the caller has: what their files take, what they may keep,
/// and what the instance's files take in all.
typedef Room = ({int used, int limit, int instanceUsed});
/// The room of the caller of [session], whose space is [space]. Uploads not
/// finished yet take room too, unless [finishedOnly]: an upload starting
/// counts them, or unfinished uploads could fill the disk unnoticed; an
/// upload finishing counts only what is kept, itself included.
Future<Room> roomOf(
Session session,
UuidValue space, {
bool finishedOnly = false,
}) async {
final limits = fileLimits;
final person = UuidValue.withValidation(
session.authenticated!.userIdentifier,
);
final allowance = await FileAllowance.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(person),
);
return (
used: await _spaceBytes(session, space, finishedOnly: finishedOnly),
limit:
allowance?.maxBytes ??
(session.authenticated!.scopes.contains(identified)
? limits.identifiedBytes
: 0),
instanceUsed: await _storedBytes(
session,
assetPathPrefix,
finishedOnly: finishedOnly,
),
);
}
/// Throws a [FileLimitExceededException] unless [room] takes [bytes] more.
void checkRoom(Room room, int bytes) {
final instance = fileLimits.instanceBytes;
final instanceFull = instance != null && room.instanceUsed + bytes > instance;
if (instanceFull || room.used + bytes > room.limit) {
throw FileLimitExceededException(
instanceFull: instanceFull,
limitBytes: room.limit,
usedBytes: room.used,
);
}
}
/// What the caller may keep in all: their limit, or less when the instance
/// has less room left.
int effectiveLimit(Room room) {
final instance = fileLimits.instanceBytes;
if (instance == null) {
return room.limit;
}
final left = instance - room.instanceUsed;
return room.used + left < room.limit ? room.used + left : room.limit;
}
/// What the stored files of [space] take: those a live asset row of it
/// names, and those claimed within [filePruningGrace] (an upload's row may
/// sync after it), unfinished uploads included unless [finishedOnly]. A
/// file no live row names any more, as one a private studyset's rewrite
/// replaced (ADR 0019), counts no longer, though pruning keeps it a while.
Future<int> _spaceBytes(
Session session,
UuidValue space, {
required bool finishedOnly,
}) async {
final prefix = '$assetPathPrefix${space.uuid}/';
final cutoff = clock
.now()
.toUtc()
.subtract(filePruningGrace)
.toIso8601String()
.replaceFirst('Z', '');
// SELECT "path", octet_length("byteData"), "addedTime" >= $3
// FROM "serverpod_cloud_storage"
// WHERE "storageId" = 'private' AND "path" LIKE $1 [AND "verified"]
final rows = await session.db.unsafeQuery(
'SELECT "path", octet_length("byteData")::bigint, '
'"addedTime" >= @cutoff::timestamp '
'FROM "serverpod_cloud_storage" '
'WHERE "storageId" = @storage AND "path" LIKE @prefix'
'${finishedOnly ? ' AND "verified"' : ''}',
parameters: QueryParameters.named({
'storage': assetStorage,
'prefix': '$prefix%',
'cutoff': cutoff,
}),
);
if (rows.isEmpty) {
return 0;
}
// SELECT "id" FROM "offline_sync_spaces" WHERE "uuidSpaceId" = $1
final spaceId = (await OfflineSyncSpace.db.findFirstRow(
session,
where: (t) => t.uuidSpaceId.equals(space),
))?.id;
// SELECT "sha256" FROM "asset" WHERE "spaceId" = $1 (tombstones hidden)
final live = spaceId == null
? const <String>{}
: {
for (final asset in await Asset.db.find(
session,
where: (t) => t.spaceId.equals(spaceId),
))
asset.sha256,
};
var used = 0;
for (final [path as String, bytes as int, recent as bool] in rows) {
if (recent || live.contains(path.substring(prefix.length))) {
used += bytes;
}
}
return used;
}
/// What the stored files below [prefix] take: unfinished uploads included,
/// unless [finishedOnly].
Future<int> _storedBytes(
Session session,
String prefix, {
required bool finishedOnly,
}) async {
// SELECT SUM(octet_length("byteData")) FROM "serverpod_cloud_storage"
// WHERE "storageId" = 'private' AND "path" LIKE $1 [AND "verified"]
final rows = await session.db.unsafeQuery(
'SELECT COALESCE(SUM(octet_length("byteData")), 0)::bigint '
'FROM "serverpod_cloud_storage" '
'WHERE "storageId" = @storage AND "path" LIKE @prefix'
'${finishedOnly ? ' AND "verified"' : ''}',
parameters: QueryParameters.named({
'storage': assetStorage,
'prefix': '$prefix%',
}),
);
return rows.first.first! as int;
}

View file

@ -1,99 +0,0 @@
import 'package:halkyon_learn_server/src/asset/asset_endpoint.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
/// How long a stored file stays after it was last claimed (uploaded, or
/// asked for again), whether or not an asset row refers to it: a device
/// uploads a file around the time its row syncs, not necessarily after.
const filePruningGrace = Duration(days: 7);
/// Deletes the stored files nobody can reach any more (ADR 0004, rule 5), at
/// most [limit] per call, and returns how many: last claimed before
/// [filePruningGrace] ago, and either never verified (an upload that wasn't
/// finished) or not referred to by any asset row of their space that still
/// exists (a deleted row is a tombstone, which the sync engine hides).
Future<int> pruneUnreferencedFiles(
Session session, {
required DateTime now,
int limit = 500,
}) async {
// The columns hold UTC as timestamp without time zone.
final timestamp = now
.toUtc()
.subtract(filePruningGrace)
.toIso8601String()
.replaceFirst('Z', '');
final candidates = await session.db.unsafeQuery(
'SELECT "path", "verified" FROM "serverpod_cloud_storage" '
'WHERE "storageId" = @storage AND "path" LIKE @prefix '
'AND "addedTime" < @cutoff::timestamp '
'ORDER BY "addedTime" LIMIT @limit',
parameters: QueryParameters.named({
'storage': assetStorage,
'prefix': '$assetPathPrefix%',
'cutoff': timestamp,
'limit': limit,
}),
);
// The candidates' hashes, by space; a path of another form isn't ours to
// judge.
final hashesBySpace = <UuidValue, Set<String>>{};
final unreferenced = <String>{};
for (final [path as String, verified as bool] in candidates) {
final match = _assetPathPattern.firstMatch(path);
if (match == null) {
continue;
}
if (!verified) {
unreferenced.add(path);
} else {
(hashesBySpace[UuidValue.withValidation(match[1]!)] ??= {}).add(
match[2]!,
);
}
}
for (final MapEntry(key: space, value: hashes) in hashesBySpace.entries) {
final spaceId = (await OfflineSyncSpace.db.findFirstRow(
session,
where: (t) => t.uuidSpaceId.equals(space),
))?.id;
final referenced = spaceId == null
? const <String>{}
: {
for (final asset in await Asset.db.find(
session,
where: (t) => t.spaceId.equals(spaceId) & t.sha256.inSet(hashes),
))
asset.sha256,
};
unreferenced.addAll([
for (final hash in hashes.difference(referenced)) assetPath(space, hash),
]);
}
if (unreferenced.isEmpty) {
return 0;
}
// A file claimed again meanwhile stays. Plain SQL, since the ORM's delete
// would read every deleted file to return it.
return await session.db.unsafeExecute(
'DELETE FROM "serverpod_cloud_storage" '
'WHERE "storageId" = @storage AND "path" = ANY(@paths::text[]) '
'AND "addedTime" < @cutoff::timestamp',
parameters: QueryParameters.named({
'storage': assetStorage,
'paths': unreferenced.toList(),
'cutoff': timestamp,
}),
);
}
/// A stored file's path: `spaces/<space>/<sha256>`.
final _assetPathPattern = RegExp(
'^${RegExp.escape(assetPathPrefix)}'
'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/'
r'([0-9a-f]{64})$',
);

View file

@ -1,5 +1,5 @@
import 'package:clock/clock.dart';
import 'package:halkyon_learn_server/src/asset/file_pruning.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:serverpod/serverpod.dart';
/// Deletes the stored files nobody can reach any more; the server schedules

View file

@ -0,0 +1,91 @@
import 'dart:typed_data';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
/// The largest readable file of any kind but a PDF, an image or a sound:
/// 20 MB. A larger one is checked to be a PDF once stored, and deleted
/// otherwise (ADR 0004, rule 5).
const maxOtherBytes = 20 * 1024 * 1024;
/// Learn's files in halkyon_base's store (Halkyon's ADR 0043, D6): the
/// `asset` rows name them, a file is read by the members of the space its
/// row is in, and a readable file over [maxOtherBytes] must be a PDF; up to
/// 100 MB, the module's default (decided 2026-10-03; 100 MB since
/// 2026-11-09, for the books of the old app's library).
const learnFiles = FileStore(
fileOfRow: _fileOfAsset,
liveHashes: _liveAssetHashes,
accepts: _accepts,
);
/// The file the asset row [assetId] names, where the caller may read its
/// space: its owner's, or one they were granted; null for a deleted row
/// (the sync engine hides tombstones) and any other.
Future<StoredFileRef?> _fileOfAsset(Session session, UuidValue assetId) async {
// SELECT * FROM "asset" WHERE "id" = $1 (tombstones hidden)
final asset = await Asset.db.findById(session, assetId);
final spaceId = asset?.spaceId;
// SELECT * FROM "offline_sync_spaces" WHERE "id" = $1
final space = spaceId == null
? null
: await OfflineSyncSpace.db.findById(session, spaceId);
final caller = UuidValue.withValidation(
session.authenticated!.userIdentifier,
);
if (asset == null ||
space == null ||
!(space.uuidSpaceId == caller ||
await OfflineSyncSpaces(
session,
).roleOf(user: caller, space: space.uuidSpaceId) !=
null)) {
return null;
}
return (space: space.uuidSpaceId, sha256: asset.sha256);
}
/// Which of [hashes] (every one, where null) the live asset rows of [space]
/// name.
Future<Set<String>> _liveAssetHashes(
Session session,
UuidValue space,
Set<String>? hashes,
) async {
// SELECT "id" FROM "offline_sync_spaces" WHERE "uuidSpaceId" = $1
final spaceId = (await OfflineSyncSpace.db.findFirstRow(
session,
where: (t) => t.uuidSpaceId.equals(space),
))?.id;
if (spaceId == null) {
return const {};
}
// SELECT "sha256" FROM "asset" WHERE "spaceId" = $1 [AND "sha256" IN …]
// (tombstones hidden)
return {
for (final asset in await Asset.db.find(
session,
where: (t) => hashes == null
? t.spaceId.equals(spaceId)
: t.spaceId.equals(spaceId) & t.sha256.inSet(hashes),
))
asset.sha256,
};
}
/// A sealed file can't be looked into, and the module checked its header;
/// a readable one over [maxOtherBytes] must be a PDF.
bool _accepts(ByteData bytes, {required bool encrypted}) =>
encrypted || bytes.lengthInBytes <= maxOtherBytes || _isPdf(bytes);
/// Whether [bytes] are a PDF's: they start as every PDF does.
bool _isPdf(ByteData bytes) {
const signature = '%PDF-';
return bytes.lengthInBytes >= signature.length &&
String.fromCharCodes(
Uint8List.sublistView(bytes, 0, signature.length),
) ==
signature;
}

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