Raise avatar cap 64 to 128 KiB, pipeline checks encoded size

64 KiB rejected normal 128px PNGs. 128 KiB fits any 128x128 PNG
(raw <= 64 KiB, worst-case base64 ~95 KiB chars) while still
bounding abuse. PNG kept for transparency. Pipeline now measures
the encoded base64 against RelayConstants (was checking the wrong
variable with its own 512 KiB limit) and drops dead crop code.
Server policy, still v4. Trade noted: avatars ride the 1 Hz room
broadcast, uplink scales with cap; hash-reference follow-up later.
This commit is contained in:
foxyz committed 2026-09-12 11:45:34 +03:00
1 parent ff154c5030
commit 190faf63c0
5 files changed
+24 -14

No files matched your search

+5 -5
View File
@@ -64,7 +64,8 @@ kick kills sync+video, no relay memory growth.
- `packages/core/lib/src/`: constants.dart (tuning: seek 300 ms, - `packages/core/lib/src/`: constants.dart (tuning: seek 300 ms,
drift 5 s, slow 1.5 s @0.95, min_buffer 4 s, heartbeat 1 s, ping 5 s, drift 5 s, slow 1.5 s @0.95, min_buffer 4 s, heartbeat 1 s, ping 5 s,
avatar 64 KiB, name 32, chat 500; dropAfter lives as RoomEngine ctor avatar 128 KiB (base64 chars; any 128x128 PNG fits — raised 09-12
from 64 KiB, which rejected normal PNGs), name 32, chat 500; dropAfter lives as RoomEngine ctor
default, now 30 s), errors.dart, logging.dart, config.dart, default, now 30 s), errors.dart, logging.dart, config.dart,
protocol.dart (v4 schemas), media_source.dart, sync_engine.dart protocol.dart (v4 schemas), media_source.dart, sync_engine.dart
(RoomEngine + quorum in evaluate's HELD branch; PLAYING branch (RoomEngine + quorum in evaluate's HELD branch; PLAYING branch
@@ -149,10 +150,9 @@ Client: 5 s room-silence ⇒ dead (clear room, warn, reconnect loop
asks-leave, global focus-proof keys (Space/Tab/arrows/ESC/F, asks-leave, global focus-proof keys (Space/Tab/arrows/ESC/F,
350 ms throttle, ignored while typing; Tab traversal disabled on 350 ms throttle, ignored while typing; Tab traversal disabled on
the session screen 09-12 — game-style, Tab toggles the sheet), single media field the session screen 09-12 — game-style, Tab toggles the sheet), single media field
(parseMediaInput + tests), avatar pipeline (128 px PNG as of (parseMediaInput + tests), avatar pipeline (128 px PNG, keeps
09-12 uncommitted change, was JPEG — NOTE: server still transparency — JPEG did not; self-check measures encoded base64
enforces 64 KiB in session.dart vs pipeline's 512 KiB check, against RelayConstants, single source of truth), tap to
mismatch open, see commit), tap to
pick, no path fields), Discord-like corner profile (connect only; pick, no path fields), Discord-like corner profile (connect only;
welcome is title+Host/Join; edits apply next join — no rename welcome is title+Host/Join; edits apply next join — no rename
intent on the wire), dialogs for terminal states. intent on the wire), dialogs for terminal states.
@@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:sync_relay_core/core.dart';
Future<String> encodeAvatar(String path) async { Future<String> encodeAvatar(String path) async {
final trimmed = path.trim(); final trimmed = path.trim();
@@ -24,22 +25,18 @@ Future<String> encodeAvatar(String path) async {
if (decoded == null) { if (decoded == null) {
throw const FormatException('could not read that image — pick a PNG, JPG or WebP photo'); throw const FormatException('could not read that image — pick a PNG, JPG or WebP photo');
} }
// final side = decoded.width < decoded.height ? decoded.width : decoded.height;
// final x = (decoded.width - side) ~/ 2;
// final y = (decoded.height - side) ~/ 2;
// final cropped = img.copyCrop(decoded, x: x, y: y, width: side, height: side);
final resized = img.copyResize( final resized = img.copyResize(
decoded, decoded,
width: 128, width: 128,
height: 128, height: 128,
maintainAspect: true, maintainAspect: true,
// backgroundColor: img.ColorFloat16.rgba(0, 0, 0, 0),
backgroundColor: img.PixelUndefined(), backgroundColor: img.PixelUndefined(),
interpolation: img.Interpolation.cubic, interpolation: img.Interpolation.cubic,
); );
final png = img.encodePng(resized); final png = img.encodePng(resized);
if (resized.length > 512 * 1024) { final b64 = base64Encode(png);
if (b64.length > RelayConstants.maxAvatarBytes) {
throw const FormatException('that photo is still too large after shrinking — pick a smaller one'); throw const FormatException('that photo is still too large after shrinking — pick a smaller one');
} }
return base64Encode(png); return b64;
} }
+1 -1
View File
@@ -73,7 +73,7 @@ silence-timeout + reconnect loop. Never run: shaper chaos suite
## Phase 6 — Identity & social (PARTIAL) ## Phase 6 — Identity & social (PARTIAL)
Shipped: names (32 cap), avatars (64 KiB, picker + crop), kick nudge Shipped: names (32 cap), avatars (128 KiB cap, 128 px PNG picker, transparency kept), kick nudge
+ reason, feed attribution. Open: real chat pane/history (schema + reason, feed attribution. Open: real chat pane/history (schema
only), sounds swap-in (wiring + NoopSfxService ready; pick only), sounds swap-in (wiring + NoopSfxService ready; pick
flutter_soloud or equivalent, Kenney/ObsydianX/akx sources in flutter_soloud or equivalent, Kenney/ObsydianX/akx sources in
+7 -1
View File
@@ -15,7 +15,13 @@ abstract class RelayConstants {
static const int stateHeartbeatSecs = 1; static const int stateHeartbeatSecs = 1;
static const int pingIntervalSecs = 5; static const int pingIntervalSecs = 5;
static const int maxAvatarBytes = 64 * 1024; // Base64-char count of the avatar string, enforced at hello. Sized so
// ANY 128x128 pipeline PNG always fits (raw bitmap <= 64 KiB, worst-case
// PNG ~70 KiB raw ~= 95 KiB base64). PNG keeps transparency; JPEG did not.
// Trade: avatars ride in the 1 Hz room broadcast, so worst-case host
// uplink scales with this cap. Follow-up (not now): send avatar bytes
// once, reference by hash afterwards.
static const int maxAvatarBytes = 128 * 1024;
static const int maxNameLength = 32; static const int maxNameLength = 32;
static const int maxChatLength = 500; static const int maxChatLength = 500;
} }
+7
View File
@@ -43,6 +43,13 @@ void main() {
expect(() => s.issueToken('alice', big), throwsA(isA<AuthException>())); expect(() => s.issueToken('alice', big), throwsA(isA<AuthException>()));
}); });
test('64 KiB avatar accepted under raised cap', () {
final s = Session(id: 'default', secret: 'shh');
final png = 'x' * (64 * 1024);
expect(s.issueToken('alice', png), isNotEmpty);
expect(s.tokens.values.single.avatar, hasLength(64 * 1024));
});
test('names truncated to cap, blank becomes ?', () { test('names truncated to cap, blank becomes ?', () {
final s = Session(id: 'default', secret: 'shh'); final s = Session(id: 'default', secret: 'shh');
final long = s.issueToken('a' * 100, ''); final long = s.issueToken('a' * 100, '');