Limit how much room people's files take
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:
parent
53b5d56e79
commit
ca69f74fd9
5 changed files with 9884 additions and 20 deletions
128
halkyon_learn_client/lib/src/protocol/asset/file_allowance.dart
Normal file
128
halkyon_learn_client/lib/src/protocol/asset/file_allowance.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
95
halkyon_learn_client/lib/src/protocol/asset/file_usage.dart
Normal file
95
halkyon_learn_client/lib/src/protocol/asset/file_usage.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue