Limit how much room people's files take
All checks were successful
CI / check (push) Successful in 6m41s
CI / deploy (push) Successful in 1m4s

An anonymous person may keep 100 MB of images and sounds, an identified one
500 MB, and one person more by a file_allowance row the operator adds; all
of them together 15 GB, since every file is in the database and in each dump
the server keeps (decided 2026-10-02). Each comes from the environment
(FILE_LIMIT_*_MB), with these defaults, documented in deploy/.env.example.

The server checks when an upload starts, counting unfinished uploads so they
can't fill the disk unnoticed, and again when it finishes, counting what is
kept, so uploads started side by side can't overshoot; a refusal is a
FileLimitExceededException, and an upload refused as it finishes is deleted.
deletePerson deletes the allowance with the person. Migration: the new
table file_allowance, only added.

The app keeps a refused file waiting on the device and stops that round of
uploads; the Anki import warns when a deck's files may not fit in the room
left (asset.usage()).

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-02 05:57:57 +02:00
commit ca69f74fd9
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 9884 additions and 20 deletions

View file

@ -0,0 +1,128 @@
/* 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

@ -0,0 +1,115 @@
/* 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;
/// A file doesn't fit: it would take the caller past what they may keep, or the
/// instance past what it keeps in all. Nothing was stored.
abstract class FileLimitExceededException
implements
_isc.SerializableException,
_isc.SerializableModel,
_isc.ProtocolSerialization {
FileLimitExceededException._({
required this.instanceFull,
required this.limitBytes,
required this.usedBytes,
});
factory FileLimitExceededException({
required bool instanceFull,
required int limitBytes,
required int usedBytes,
}) = _FileLimitExceededExceptionImpl;
factory FileLimitExceededException.fromJson(
Map<String, dynamic> jsonSerialization,
) {
return FileLimitExceededException(
instanceFull: _isc.BoolJsonExtension.fromJson(
jsonSerialization['instanceFull'],
),
limitBytes: jsonSerialization['limitBytes'] as int,
usedBytes: jsonSerialization['usedBytes'] as int,
);
}
/// Whether the instance is full, rather than the caller's own room.
bool instanceFull;
/// What the caller may keep, in bytes.
int limitBytes;
/// What the caller keeps now, in bytes.
int usedBytes;
/// Returns a shallow copy of this [FileLimitExceededException]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
FileLimitExceededException copyWith({
bool? instanceFull,
int? limitBytes,
int? usedBytes,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'FileLimitExceededException',
'instanceFull': instanceFull,
'limitBytes': limitBytes,
'usedBytes': usedBytes,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'FileLimitExceededException',
'instanceFull': instanceFull,
'limitBytes': limitBytes,
'usedBytes': usedBytes,
};
}
@override
String toString() {
return 'FileLimitExceededException(instanceFull: $instanceFull, limitBytes: $limitBytes, usedBytes: $usedBytes)';
}
}
class _FileLimitExceededExceptionImpl extends FileLimitExceededException {
_FileLimitExceededExceptionImpl({
required bool instanceFull,
required int limitBytes,
required int usedBytes,
}) : super._(
instanceFull: instanceFull,
limitBytes: limitBytes,
usedBytes: usedBytes,
);
/// Returns a shallow copy of this [FileLimitExceededException]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
FileLimitExceededException copyWith({
bool? instanceFull,
int? limitBytes,
int? usedBytes,
}) {
return FileLimitExceededException(
instanceFull: instanceFull ?? this.instanceFull,
limitBytes: limitBytes ?? this.limitBytes,
usedBytes: usedBytes ?? this.usedBytes,
);
}
}

View file

@ -0,0 +1,95 @@
/* 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;
/// How much room the caller's files take, and how much they may take.
abstract class FileUsage
implements _isc.SerializableModel, _isc.ProtocolSerialization {
FileUsage._({
required this.usedBytes,
required this.limitBytes,
});
factory FileUsage({
required int usedBytes,
required int limitBytes,
}) = _FileUsageImpl;
factory FileUsage.fromJson(Map<String, dynamic> jsonSerialization) {
return FileUsage(
usedBytes: jsonSerialization['usedBytes'] as int,
limitBytes: jsonSerialization['limitBytes'] as int,
);
}
/// What their files take now, in bytes.
int usedBytes;
/// What they may keep, in bytes: their own limit, or less when the instance
/// has less room left.
int limitBytes;
/// Returns a shallow copy of this [FileUsage]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
FileUsage copyWith({
int? usedBytes,
int? limitBytes,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'FileUsage',
'usedBytes': usedBytes,
'limitBytes': limitBytes,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'FileUsage',
'usedBytes': usedBytes,
'limitBytes': limitBytes,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _FileUsageImpl extends FileUsage {
_FileUsageImpl({
required int usedBytes,
required int limitBytes,
}) : super._(
usedBytes: usedBytes,
limitBytes: limitBytes,
);
/// Returns a shallow copy of this [FileUsage]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
FileUsage copyWith({
int? usedBytes,
int? limitBytes,
}) {
return FileUsage(
usedBytes: usedBytes ?? this.usedBytes,
limitBytes: limitBytes ?? this.limitBytes,
);
}
}

View file

@ -12,6 +12,8 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _ida;
import 'dart:typed_data' as _idt;
import 'package:halkyon_learn_client/src/protocol/asset/file_usage.dart'
as _iimptq0q;
import 'package:halkyon_learn_client/src/protocol/auth/device.dart'
as _ieqh4yr9;
import 'package:halkyon_learn_client/src/protocol/instance/instance_info.dart'
@ -47,7 +49,8 @@ class EndpointAsset extends _isc.EndpointRef {
String get name => 'asset';
/// How to upload the caller's file with [sha256] (lowercase hex) of
/// [byteSize] bytes; null when the server has it already.
/// [byteSize] bytes; null when the server has it already. Throws a
/// [FileLimitExceededException] when it doesn't fit (see [FileLimits]).
_ida.Future<String?> startUpload(
String sha256,
int byteSize,
@ -61,7 +64,8 @@ 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.
/// stored now. A file whose content has another hash is deleted, and so is
/// one that no longer fits, with a [FileLimitExceededException].
_ida.Future<bool> finishUpload(String sha256) =>
caller.callServerEndpoint<bool>(
'asset',
@ -69,6 +73,14 @@ class EndpointAsset extends _isc.EndpointRef {
{'sha256': sha256},
);
/// How much room the caller's files take, and may take.
_ida.Future<_iimptq0q.FileUsage> usage() =>
caller.callServerEndpoint<_iimptq0q.FileUsage>(
'asset',
'usage',
{},
);
/// 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.

View file

@ -22,6 +22,9 @@ 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 'auth/device.dart' as _i6c5ny7n;
import 'auth/github_sign_in_refusal.dart' as _ir1gd9p5;
import 'auth/github_sign_in_refused_exception.dart' as _i0xfcu5n;
@ -35,6 +38,9 @@ import 'studyset/studyset.dart' as _ikbclmae;
import 'tag/card_tag.dart' as _i6jz6nyq;
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 'auth/device.dart';
export 'auth/github_sign_in_refusal.dart';
export 'auth/github_sign_in_refused_exception.dart';
@ -885,6 +891,15 @@ 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;
}
if (t == _iurwo9k8.FileUsage) {
return _iurwo9k8.FileUsage.fromJson(data) as T;
}
if (t == _i6c5ny7n.Device) {
return _i6c5ny7n.Device.fromJson(data) as T;
}
@ -924,6 +939,19 @@ 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)
: null)
as T;
}
if (t == _isc.getType<_iurwo9k8.FileUsage?>()) {
return (data != null ? _iurwo9k8.FileUsage.fromJson(data) : null) as T;
}
if (t == _isc.getType<_i6c5ny7n.Device?>()) {
return (data != null ? _i6c5ny7n.Device.fromJson(data) : null) as T;
}
@ -993,6 +1021,9 @@ 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',
_i6c5ny7n.Device => 'Device',
_ir1gd9p5.GitHubSignInRefusal => 'GitHubSignInRefusal',
_i0xfcu5n.GitHubSignInRefusedException => 'GitHubSignInRefusedException',
@ -1024,6 +1055,12 @@ 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():
return 'FileUsage';
case _i6c5ny7n.Device():
return 'Device';
case _ir1gd9p5.GitHubSignInRefusal():
@ -1079,6 +1116,15 @@ 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']);
}
if (dataClassName == 'FileUsage') {
return deserialize<_iurwo9k8.FileUsage>(data['data']);
}
if (dataClassName == 'Device') {
return deserialize<_i6c5ny7n.Device>(data['data']);
}