fix(privacy): the instance's side of the reading of private studysets (ADR 0019)

An upload said to be encrypted must start as a sealed file does (HLKF,
version 1), so a readable file can't skip the PDF check by saying so. A
person's room counts the files a live asset row names and those claimed
within the week of grace, so a private studyset's rewrite near the limit
no longer fails for a week while pruning keeps the replaced files. Copy
reports are limited to 60 an hour per person. Tests: what goes with the
person, `counted` refused to anonymous people.

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-22 08:45:01 +02:00
commit d9b5e06618
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 204 additions and 13 deletions

View file

@ -76,8 +76,9 @@ class AssetEndpoint extends Endpoint {
/// 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: its size alone counts, as the quotas'
/// sizes do.
/// (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.
Future<bool> finishUpload(
Session session,
String sha256, {
@ -96,9 +97,9 @@ class AssetEndpoint extends Endpoint {
path: path,
);
if (_hashOf(bytes) != sha256 ||
(!(encrypted ?? false) &&
bytes.lengthInBytes > maxOtherBytes &&
!_isPdf(bytes)) ||
(encrypted ?? false
? !_isSealed(bytes)
: bytes.lengthInBytes > maxOtherBytes && !_isPdf(bytes)) ||
bytes.lengthInBytes > maxBytes + encryptionOverhead) {
await session.storage.deleteFile(storageId: assetStorage, path: path);
return false;
@ -183,5 +184,17 @@ bool _isPdf(ByteData bytes) {
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();

View file

@ -1,9 +1,12 @@
import 'dart:io';
import 'package:halkyon_base_server/base.dart';
import 'package:clock/clock.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): an anonymous
/// person little, since anonymous accounts are meant to become identified
@ -80,11 +83,7 @@ Future<Room> roomOf(
where: (t) => t.authUserId.equals(person),
);
return (
used: await _storedBytes(
session,
'$assetPathPrefix${space.uuid}/',
finishedOnly: finishedOnly,
),
used: await _spaceBytes(session, space, finishedOnly: finishedOnly),
limit:
allowance?.maxBytes ??
(session.authenticated!.scopes.contains(identified)
@ -122,6 +121,65 @@ int effectiveLimit(Room room) {
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(

View file

@ -4,6 +4,19 @@ import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// The most reports a person's devices may send an hour: each device
/// reports after its syncs, when what it reports changed.
const copyReportsPerHour = 60;
final _copyReports = DatabaseRateLimiter(
RateLimiterConfig(
domain: 'learning_count',
source: 'copy_reports_per_person',
maxAttempts: copyReportsPerHour,
timeframe: const Duration(hours: 1),
),
);
/// Whether the caller is counted as learning what their copies come from
/// ("Learn this here", ADR 0005, rule 8), and which publications those are
/// (ADR 0019, D3): their settings and private copies are encrypted, so their
@ -67,8 +80,9 @@ class LearningCountEndpoint extends Endpoint {
/// Replaces the copies the caller's device reported before with
/// [copies]; whether they were kept, which they are only while the
/// caller chose to be counted. Which of them are really the caller's,
/// and still there, the count works out from their own rows.
/// caller chose to be counted, and for at most [copyReportsPerHour]
/// reports an hour. Which of them are really the caller's, and still
/// there, the count works out from their own rows.
Future<bool> reportCopies(Session session, List<CopyReport> copies) async {
if (copies.length > maxPublications ||
copies.fold(0, (sum, copy) => sum + copy.studysetIds.length) >
@ -76,6 +90,9 @@ class LearningCountEndpoint extends Endpoint {
throw ArgumentError.value(copies.length, 'copies', 'too many');
}
final person = session.authenticated!.authUserId;
if (!await _copyReports.tryRecordAttempt(session, key: person.uuid)) {
return false;
}
return await session.db.transaction((transaction) async {
// SELECT count(*) FROM "counted_learner" WHERE "authUserId" = $1
if (await CountedLearner.db.count(