Skip to content

Commit 4c6e1e0

Browse files
fix audio issue, implement file migration to new encryption
1 parent 403c36c commit 4c6e1e0

10 files changed

Lines changed: 224 additions & 21 deletions

File tree

school_data_hub_client/lib/src/protocol/client.dart

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -402,8 +402,7 @@ class EndpointAdminUser extends _i1.EndpointRef {
402402
},
403403
);
404404

405-
/// Batch-creates users. Returns credentials for successes and errors for skipped/failed rows.
406-
/// Each create runs in its own transaction; creates are executed in parallel (up to 5 at a time).
405+
/// Batch-creates users sequentially. Returns credentials for successes and errors for skipped/failed rows.
407406
/// Duplicates are detected by the DB (unique constraint); we catch 23505 and report a friendly message.
408407
_i2.Future<_i13.BatchCreateUsersResponse> batchCreateUsers(
409408
List<_i14.CreateUserRequest> requests) =>
@@ -3116,6 +3115,22 @@ class EndpointFiles extends _i1.EndpointRef {
31163115
'getUnencryptedImage',
31173116
{'path': path},
31183117
);
3118+
3119+
/// Overwrites the stored bytes for the file identified by [documentId] with
3120+
/// [newEncryptedBytes] without touching the [HubDocument] record
3121+
/// (preserves [createdBy], [createdAt], etc.).
3122+
_i2.Future<bool> replaceEncryptedFileBytes(
3123+
String documentId,
3124+
_i66.ByteData newEncryptedBytes,
3125+
) =>
3126+
caller.callServerEndpoint<bool>(
3127+
'files',
3128+
'replaceEncryptedFileBytes',
3129+
{
3130+
'documentId': documentId,
3131+
'newEncryptedBytes': newEncryptedBytes,
3132+
},
3133+
);
31193134
}
31203135

31213136
class Modules {

school_data_hub_flutter/lib/app_utils/custom_encrypter.dart

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,20 @@ bool _looksLikeImage(Uint8List bytes) {
7575
(bytes[0] == 0xFF && bytes[1] == 0xD8); // JPEG
7676
}
7777

78+
/// Returns true if [bytes] look like a known audio format (magic bytes).
79+
bool _looksLikeAudio(Uint8List bytes) {
80+
if (bytes.length < 8) return false;
81+
// M4A / MP4 / AAC — ISO Base Media: 'ftyp' box starts at offset 4.
82+
if (bytes[4] == 0x66 && bytes[5] == 0x74 && bytes[6] == 0x79 && bytes[7] == 0x70) return true;
83+
// MP3 — MPEG sync word (0xFFE0..0xFFFF).
84+
if (bytes[0] == 0xFF && (bytes[1] & 0xE0) == 0xE0) return true;
85+
// WAV — 'RIFF' header.
86+
if (bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46) return true;
87+
// OGG — 'OggS' capture pattern.
88+
if (bytes[0] == 0x4F && bytes[1] == 0x67 && bytes[2] == 0x67 && bytes[3] == 0x53) return true;
89+
return false;
90+
}
91+
7892
// --- CustomEncrypter (uses EnvManager on main isolate only) ---
7993

8094
final customEncrypter = CustomEncrypter();
@@ -252,7 +266,7 @@ class CustomEncrypter {
252266
final newResult = Uint8List.fromList(
253267
_decryptCbc(iv, ciphertext, _keyBytes),
254268
);
255-
if (_looksLikeImage(newResult)) return newResult;
269+
if (_looksLikeImage(newResult) || _looksLikeAudio(newResult)) return newResult;
256270
// Legacy format: entire blob is ciphertext, fixed IV from env (old encrypt package).
257271
if (encryptedBytes.length % 16 != 0) return newResult;
258272
final fixedIv = _fixedIv();
@@ -272,7 +286,7 @@ class CustomEncrypter {
272286
encryptedBytes,
273287
keyBytes,
274288
]);
275-
if (_looksLikeImage(result)) return result;
289+
if (_looksLikeImage(result) || _looksLikeAudio(result)) return result;
276290
// Legacy format: ciphertext-only with fixed IV (old encrypt package).
277291
if (encryptedBytes.length % 16 != 0) return result;
278292
final fixedIv = _fixedIv();
@@ -290,4 +304,48 @@ class CustomEncrypter {
290304
final encrypted = _encryptCbc(bytes, _keyBytes, iv);
291305
return Uint8List.fromList([...iv, ...encrypted]);
292306
}
307+
308+
/// Decrypts [encryptedBytes] and indicates whether the input was in the
309+
/// legacy format (ciphertext-only, fixed IV) vs. the new format (IV prepended).
310+
///
311+
/// Returns `({Uint8List bytes, bool wasLegacy})`.
312+
/// Use [wasLegacy] to trigger a fire-and-forget re-encryption migration.
313+
Future<({Uint8List bytes, bool wasLegacy})> decryptFileBytesAsync(
314+
Uint8List encryptedBytes) async {
315+
if (encryptedBytes.length <= 16) {
316+
return (bytes: encryptedBytes, wasLegacy: false);
317+
}
318+
final keyBytes = Uint8List.fromList(
319+
utf8.encode(di<EnvManager>().activeEnv!.key!),
320+
);
321+
322+
// Try new format first (IV prepended).
323+
final newResult = kReleaseMode || kProfileMode
324+
? await compute(decryptBytesWithKey, <dynamic>[encryptedBytes, keyBytes])
325+
: decryptTheseBytes(encryptedBytes);
326+
327+
if (_looksLikeImage(newResult) || _looksLikeAudio(newResult)) {
328+
return (bytes: newResult, wasLegacy: false);
329+
}
330+
331+
// Legacy format: ciphertext-only, fixed IV.
332+
if (encryptedBytes.length % 16 != 0) {
333+
// Not block-aligned — new format result is the best we can do.
334+
return (bytes: newResult, wasLegacy: false);
335+
}
336+
337+
final fixedIv = _fixedIv();
338+
final legacyResult = kReleaseMode || kProfileMode
339+
? await compute(decryptBytesWithKey,
340+
<dynamic>[encryptedBytes, keyBytes, fixedIv])
341+
: Uint8List.fromList(
342+
_decryptCbc(fixedIv, encryptedBytes, _keyBytes));
343+
344+
if (_looksLikeImage(legacyResult) || _looksLikeAudio(legacyResult)) {
345+
return (bytes: legacyResult, wasLegacy: true);
346+
}
347+
348+
// Cannot determine format — return new-format result, not legacy.
349+
return (bytes: newResult, wasLegacy: false);
350+
}
293351
}

school_data_hub_flutter/lib/app_utils/download_and_decrypt_file.dart

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,19 @@ Future<File?> downloadAndDecryptFile({
2424
}
2525

2626
final fileBytes = await fileInfo.file.readAsBytes();
27-
final decryptedBytes = await customEncrypter.decryptTheseBytesAsync(
28-
fileBytes,
29-
);
27+
final (:bytes, :wasLegacy) =
28+
await customEncrypter.decryptFileBytesAsync(fileBytes);
29+
30+
if (wasLegacy) {
31+
_migrateToNewFormat(documentId, bytes, cacheManager);
32+
}
3033

3134
final tempDir = await Directory.systemTemp.createTemp();
3235
final extension = p.extension(documentId);
3336
final tempFile = File(
3437
'${tempDir.path}/decrypted_${documentId.hashCode}$extension',
3538
);
36-
await tempFile.writeAsBytes(decryptedBytes);
39+
await tempFile.writeAsBytes(bytes);
3740
return tempFile;
3841
}
3942

@@ -62,15 +65,41 @@ Future<File?> downloadAndDecryptFile({
6265
return tempFile;
6366
}
6467

65-
final decryptedBytes = await customEncrypter.decryptTheseBytesAsync(
66-
fileBytes,
67-
);
68+
final (:bytes, :wasLegacy) =
69+
await customEncrypter.decryptFileBytesAsync(fileBytes);
70+
71+
if (wasLegacy) {
72+
_migrateToNewFormat(documentId, bytes, cacheManager);
73+
}
6874

6975
final tempDir = await Directory.systemTemp.createTemp();
7076
final extension = p.extension(documentId);
7177
final tempFile = File(
7278
'${tempDir.path}/decrypted_${documentId.hashCode}$extension',
7379
);
74-
await tempFile.writeAsBytes(decryptedBytes);
80+
await tempFile.writeAsBytes(bytes);
7581
return tempFile;
7682
}
83+
84+
/// Fire-and-forget: re-encrypt [plainBytes] in the new format, update the
85+
/// server file, and refresh the local cache — all without blocking the caller.
86+
void _migrateToNewFormat(
87+
String documentId,
88+
Uint8List plainBytes,
89+
DefaultCacheManager cacheManager,
90+
) {
91+
Future(() async {
92+
try {
93+
final newEncrypted = customEncrypter.encryptTheseBytes(plainBytes);
94+
final byteData = ByteData.sublistView(newEncrypted);
95+
final ok = await di<Client>()
96+
.files
97+
.replaceEncryptedFileBytes(documentId, byteData);
98+
if (ok) {
99+
await cacheManager.putFile(documentId, newEncrypted);
100+
}
101+
} catch (_) {
102+
// Migration is best-effort; silently ignore failures.
103+
}
104+
});
105+
}

school_data_hub_flutter/lib/common/widgets/generic_components/generic_app_bar.dart

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ class GenericAppBar extends StatelessWidget implements PreferredSizeWidget {
6060
title: Row(
6161
mainAxisAlignment: MainAxisAlignment.center,
6262
children: [
63+
const Padding(
64+
padding: EdgeInsets.only(left: 5),
65+
child: HubConnectionStateIndicator(),
66+
),
6367
Expanded(
6468
child: Center(
6569
child: SingleChildScrollView(
@@ -76,10 +80,6 @@ class GenericAppBar extends StatelessWidget implements PreferredSizeWidget {
7680
),
7781
),
7882
),
79-
const Padding(
80-
padding: EdgeInsets.only(right: 5),
81-
child: HubConnectionStateIndicator(),
82-
),
8383
],
8484
),
8585
);

school_data_hub_flutter/lib/core/client/client_helper.dart

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:flutter_it/flutter_it.dart';
22
import 'package:school_data_hub_client/school_data_hub_client.dart';
3+
import 'package:school_data_hub_flutter/common/services/hub_stream_service.dart';
34
import 'package:school_data_hub_flutter/common/services/notification_service.dart';
45
import 'package:school_data_hub_flutter/core/session/hub_session_manager.dart';
56

@@ -23,20 +24,33 @@ class ClientHelper {
2324
return result;
2425
} on ServerpodClientException catch (e) {
2526
_notificationService.apiRunning(false);
27+
28+
// 502 / 503 during a server restart are expected transients.
29+
// Suppress them while the hub stream is not yet connected so the
30+
// reconnect-flush doesn't clutter the overlay with gateway errors.
31+
if (e.statusCode == 502 || e.statusCode == 503) {
32+
try {
33+
final hub = di<HubStreamService>();
34+
if (hub.connectionState.value != HubConnectionState.connected) {
35+
return null;
36+
}
37+
} catch (_) {
38+
// HubStreamService not yet registered — fall through to show error.
39+
}
40+
}
41+
2642
_notificationService.showInformationDialog(
2743
NotificationType.error,
2844
'API Fehler: ${errorMessage ?? "Unbekannt"}: $e',
2945
);
3046

3147
if (e.toString().contains('Not authorized') ||
3248
e.toString().contains('401')) {
33-
// Handle authentication error specifically
3449
_notificationService.showInformationDialog(
3550
NotificationType.error,
3651
'Authentication required. Please log in again.',
3752
);
3853
_hubSessionManager.signOutDevice();
39-
// Optionally, trigger a logout or redirect to login
4054
}
4155
return null;
4256
} catch (e) {

school_data_hub_flutter/lib/features/timetable/presentation/room_timetable_grid/room_timetable_grid_widget.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class _RoomTimetableGridWidgetState extends State<RoomTimetableGridWidget> {
2525
static const double _timeColumnWidth = 64;
2626
static const double _roomHeaderHeight = 48;
2727
static const double _minSlotHeight = 12;
28-
static const double _maxSlotHeight = 16;
28+
static const double _maxSlotHeight = 12;
2929

3030
double _slotHeight = 40;
3131
double _slotHeightAtScaleStart = 40;

school_data_hub_server/lib/src/_shared/endpoints/file_endpoints.dart

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,31 @@ class FilesEndpoint extends Endpoint {
7272
path: path,
7373
);
7474
}
75+
76+
// TODO: delete when no longer needed (legacy -> new encryption format migration)
77+
/// Overwrites the stored bytes for the file identified by [documentId] with
78+
/// [newEncryptedBytes] without touching the [HubDocument] record
79+
/// (preserves [createdBy], [createdAt], etc.).
80+
Future<bool> replaceEncryptedFileBytes(
81+
Session session, String documentId, ByteData newEncryptedBytes) async {
82+
final HubDocument? document = await HubDocument.db
83+
.findFirstRow(session, where: (t) => t.documentId.equals(documentId));
84+
if (document == null) return false;
85+
final path = document.documentPath;
86+
if (path == null) return false;
87+
final exists = await session.storage.fileExists(
88+
storageId: 'private',
89+
path: path,
90+
);
91+
if (!exists) return false;
92+
await session.storage.storeFile(
93+
storageId: 'private',
94+
path: path,
95+
byteData: newEncryptedBytes,
96+
);
97+
session.log(
98+
level: LogLevel.info,
99+
'File with documentId $documentId replaced: $path');
100+
return true;
101+
}
75102
}

school_data_hub_server/lib/src/generated/endpoints.dart

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,8 @@ import 'package:school_data_hub_server/src/generated/_features/workbooks/models/
164164
as _i91;
165165
import 'package:school_data_hub_server/src/generated/_features/workbooks/models/workbook.dart'
166166
as _i92;
167-
import 'package:serverpod_auth_server/serverpod_auth_server.dart' as _i93;
167+
import 'dart:typed_data' as _i93;
168+
import 'package:serverpod_auth_server/serverpod_auth_server.dart' as _i94;
168169

169170
class Endpoints extends _i1.EndpointDispatch {
170171
@override
@@ -6485,8 +6486,33 @@ class Endpoints extends _i1.EndpointDispatch {
64856486
params['path'],
64866487
),
64876488
),
6489+
'replaceEncryptedFileBytes': _i1.MethodConnector(
6490+
name: 'replaceEncryptedFileBytes',
6491+
params: {
6492+
'documentId': _i1.ParameterDescription(
6493+
name: 'documentId',
6494+
type: _i1.getType<String>(),
6495+
nullable: false,
6496+
),
6497+
'newEncryptedBytes': _i1.ParameterDescription(
6498+
name: 'newEncryptedBytes',
6499+
type: _i1.getType<_i93.ByteData>(),
6500+
nullable: false,
6501+
),
6502+
},
6503+
call: (
6504+
_i1.Session session,
6505+
Map<String, dynamic> params,
6506+
) async =>
6507+
(endpoints['files'] as _i45.FilesEndpoint)
6508+
.replaceEncryptedFileBytes(
6509+
session,
6510+
params['documentId'],
6511+
params['newEncryptedBytes'],
6512+
),
6513+
),
64886514
},
64896515
);
6490-
modules['serverpod_auth'] = _i93.Endpoints()..initializeEndpoints(server);
6516+
modules['serverpod_auth'] = _i94.Endpoints()..initializeEndpoints(server);
64916517
}
64926518
}

school_data_hub_server/lib/src/generated/protocol.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,3 +299,4 @@ files:
299299
- verifyUpload:
300300
- getImage:
301301
- getUnencryptedImage:
302+
- replaceEncryptedFileBytes:

school_data_hub_server/test/integration/test_tools/serverpod_test_tools.dart

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8974,4 +8974,37 @@ class _FilesEndpoint {
89748974
}
89758975
});
89768976
}
8977+
8978+
_i3.Future<bool> replaceEncryptedFileBytes(
8979+
_i1.TestSessionBuilder sessionBuilder,
8980+
String documentId,
8981+
_i67.ByteData newEncryptedBytes,
8982+
) async {
8983+
return _i1.callAwaitableFunctionAndHandleExceptions(() async {
8984+
var _localUniqueSession =
8985+
(sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild(
8986+
endpoint: 'files',
8987+
method: 'replaceEncryptedFileBytes',
8988+
);
8989+
try {
8990+
var _localCallContext = await _endpointDispatch.getMethodCallContext(
8991+
createSessionCallback: (_) => _localUniqueSession,
8992+
endpointPath: 'files',
8993+
methodName: 'replaceEncryptedFileBytes',
8994+
parameters: _i1.testObjectToJson({
8995+
'documentId': documentId,
8996+
'newEncryptedBytes': newEncryptedBytes,
8997+
}),
8998+
serializationManager: _serializationManager,
8999+
);
9000+
var _localReturnValue = await (_localCallContext.method.call(
9001+
_localUniqueSession,
9002+
_localCallContext.arguments,
9003+
) as _i3.Future<bool>);
9004+
return _localReturnValue;
9005+
} finally {
9006+
await _localUniqueSession.close();
9007+
}
9008+
});
9009+
}
89779010
}

0 commit comments

Comments
 (0)