1.0 shell: one window with a party strip; protocol 9 profile + avatars by hash
The shell (lib/src/shell/) replaces the Home and session screens:
- Party strip: profile, Library / Schedule / Settings tabs, Join and
Host cards (hosting no longer takes media), status-ring avatar stack
with the member list, chat & activity panel, Leave.
- The real session player is mounted once and shown full, as a
draggable corner-snapping mini player, or fullscreen (window_manager,
party and toasts overlaid); the Video is never mounted twice.
- Profile popover with inline rename, colour and a crop picker (256 px
PNG), pushed live to the session; avatar toasts; waiting pill.
- Motion kit (fastEaseInToSlowEaseOut, fade-through, popovers,
mouse-drag/wheel horizontal scrolling).
- Library = the existing picker for now (restyled next round); Schedule
= the new SchedulePage; Settings = left nav over the existing panels.
Protocol 9 (packages/core, packages/server, sync client):
- {"t":"profile"} renames / changes the picture mid-session ("rename",
"avatar" feed actions); names are cleaned the same way at hello.
- state.members carry avatarHash; pictures are served once from
/a/<sid>/<token>/<hash> (PNG/JPEG/WebP/GIF by magic bytes only) and
cached by hash in the app (AvatarCache).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f790470935
commit
d29c12e4f0
62 files changed
+5777
-3572
No files matched your search
@@ -1,5 +1,5 @@
|
||||
abstract class RelayConstants {
|
||||
static const int protoVersion = 8;
|
||||
static const int protoVersion = 9;
|
||||
|
||||
// Relay (`/r/...`): a member's bytes through the server's buffer.
|
||||
static const int relaySliceBytes = 1 << 20;
|
||||
@@ -32,13 +32,11 @@ abstract class RelayConstants {
|
||||
static const int stateHeartbeatSecs = 1;
|
||||
static const int pingIntervalSecs = 5;
|
||||
|
||||
// Base64-char count of the avatar string, enforced at hello. 256 KiB
|
||||
// leaves wide headroom over any 128x128 pipeline PNG (raw bitmap
|
||||
// <= 64 KiB, worst-case base64 ~95 KiB chars) — deliberate over-size
|
||||
// for private-server use. PNG keeps transparency; JPEG did not.
|
||||
// Trade: avatars ride in the 1 Hz state broadcast, so worst-case host
|
||||
// uplink scales with this cap. Follow-up (not now): send avatar bytes
|
||||
// once, reference by hash afterwards.
|
||||
// Base64-char count of the avatar string, enforced at hello and
|
||||
// profile. 256 KiB leaves wide headroom over any 128x128 pipeline PNG
|
||||
// (raw bitmap <= 64 KiB, worst-case base64 ~95 KiB chars) — deliberate
|
||||
// over-size for private-server use. PNG keeps transparency; JPEG did
|
||||
// not. Only the hash rides in `state`; the bytes are fetched once.
|
||||
static const int maxAvatarBytes = 256 * 1024;
|
||||
static const int maxNameLength = 32;
|
||||
static const int maxChatLength = 500;
|
||||
|
||||
@@ -18,6 +18,14 @@ int finiteInt(Object? value, String field, {int fallback = 0}) {
|
||||
return n.toInt();
|
||||
}
|
||||
|
||||
/// A member name as shown to everyone: trimmed, cut to
|
||||
/// [RelayConstants.maxNameLength], `?` when empty.
|
||||
String cleanMemberName(String name) {
|
||||
final trimmed = name.trim();
|
||||
if (trimmed.isEmpty) return '?';
|
||||
return trimmed.substring(0, trimmed.length.clamp(0, RelayConstants.maxNameLength));
|
||||
}
|
||||
|
||||
class HelloUser {
|
||||
final String name;
|
||||
final String avatar;
|
||||
@@ -450,10 +458,32 @@ class ChatMessage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes this member's name and/or picture mid-session. Null fields stay
|
||||
/// as they are; an empty [avatar] removes the picture.
|
||||
class ProfileIntent {
|
||||
final String? name;
|
||||
/// Base64 PNG, like `hello.user.avatar`.
|
||||
final String? avatar;
|
||||
const ProfileIntent({this.name, this.avatar});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
't': 'profile',
|
||||
if (name != null) 'name': name,
|
||||
if (avatar != null) 'avatar': avatar,
|
||||
};
|
||||
|
||||
factory ProfileIntent.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'profile') throw const ProtocolException('not a profile intent');
|
||||
return ProfileIntent(name: json['name'] as String?, avatar: json['avatar'] as String?);
|
||||
}
|
||||
}
|
||||
|
||||
class MemberView {
|
||||
final String id;
|
||||
final String name;
|
||||
final String avatar;
|
||||
/// Content hash of the member's picture, fetched at `/a/...`; empty when
|
||||
/// none.
|
||||
final String avatarHash;
|
||||
final bool ready;
|
||||
final bool inSet;
|
||||
final double buffered;
|
||||
@@ -463,7 +493,7 @@ class MemberView {
|
||||
const MemberView({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.avatar = '',
|
||||
this.avatarHash = '',
|
||||
required this.ready,
|
||||
required this.inSet,
|
||||
required this.buffered,
|
||||
@@ -474,7 +504,7 @@ class MemberView {
|
||||
Map<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'avatar': avatar,
|
||||
if (avatarHash.isNotEmpty) 'avatarHash': avatarHash,
|
||||
'ready': ready,
|
||||
'inSet': inSet,
|
||||
'buffered': buffered,
|
||||
@@ -485,7 +515,7 @@ class MemberView {
|
||||
factory MemberView.fromJson(Map<String, Object?> json) => MemberView(
|
||||
id: (json['id'] as String?) ?? '',
|
||||
name: (json['name'] as String?) ?? '',
|
||||
avatar: (json['avatar'] as String?) ?? '',
|
||||
avatarHash: (json['avatarHash'] as String?) ?? '',
|
||||
ready: (json['ready'] as bool?) ?? false,
|
||||
inSet: (json['inSet'] as bool?) ?? false,
|
||||
buffered: finiteDouble(json['buffered'], 'buffered'),
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
import 'constants.dart';
|
||||
import 'protocol.dart';
|
||||
|
||||
@@ -14,11 +19,34 @@ Correction correctionFor({required double ownPosition, required double serverPos
|
||||
return Correction.none;
|
||||
}
|
||||
|
||||
/// Content hash of a picture: the first 16 hex chars of its sha256.
|
||||
String avatarHashOf(List<int> bytes) => sha256.convert(bytes).toString().substring(0, 16);
|
||||
|
||||
/// `(type, subtype)` of a picture by its magic bytes; null for anything
|
||||
/// that is not PNG, JPEG, WebP or GIF (those are never stored or served).
|
||||
(String, String)? avatarImageType(List<int> b) {
|
||||
bool at(int i, List<int> sig) {
|
||||
if (b.length < i + sig.length) return false;
|
||||
for (var k = 0; k < sig.length; k++) {
|
||||
if (b[i + k] != sig[k]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (at(0, const [0x89, 0x50, 0x4E, 0x47])) return ('image', 'png');
|
||||
if (at(0, const [0xFF, 0xD8, 0xFF])) return ('image', 'jpeg');
|
||||
if (at(0, const [0x52, 0x49, 0x46, 0x46]) && at(8, const [0x57, 0x45, 0x42, 0x50])) return ('image', 'webp');
|
||||
if (at(0, const [0x47, 0x49, 0x46, 0x38])) return ('image', 'gif');
|
||||
return null;
|
||||
}
|
||||
|
||||
class EngineMember {
|
||||
final String id;
|
||||
final String token;
|
||||
String name;
|
||||
String avatar;
|
||||
/// Picture bytes, served at `/a/...`; only [avatarHash] is broadcast.
|
||||
Uint8List? avatarBytes;
|
||||
String avatarHash = '';
|
||||
bool ready = false;
|
||||
bool inSet = false;
|
||||
double buffered = 0;
|
||||
@@ -29,14 +57,35 @@ class EngineMember {
|
||||
required this.id,
|
||||
required this.token,
|
||||
required this.name,
|
||||
this.avatar = '',
|
||||
String avatar = '',
|
||||
required DateTime now,
|
||||
}) : lastSeen = now;
|
||||
}) : lastSeen = now {
|
||||
setAvatar(avatar);
|
||||
}
|
||||
|
||||
/// Sets the picture from base64 (empty or undecodable: none). Returns
|
||||
/// whether it changed.
|
||||
bool setAvatar(String base64) {
|
||||
Uint8List? bytes;
|
||||
if (base64.isNotEmpty) {
|
||||
try {
|
||||
bytes = base64Decode(base64);
|
||||
} on FormatException {
|
||||
bytes = null;
|
||||
}
|
||||
}
|
||||
if (bytes != null && avatarImageType(bytes) == null) bytes = null;
|
||||
final hash = bytes == null || bytes.isEmpty ? '' : avatarHashOf(bytes);
|
||||
if (hash == avatarHash) return false;
|
||||
avatarHash = hash;
|
||||
avatarBytes = hash.isEmpty ? null : bytes;
|
||||
return true;
|
||||
}
|
||||
|
||||
MemberView view({required bool stale}) => MemberView(
|
||||
id: id,
|
||||
name: name,
|
||||
avatar: avatar,
|
||||
avatarHash: avatarHash,
|
||||
ready: ready,
|
||||
inSet: inSet,
|
||||
buffered: buffered,
|
||||
@@ -129,6 +178,32 @@ class SyncEngine {
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Name and/or picture change ([ProfileIntent]); null leaves it, an
|
||||
/// empty [avatar] removes the picture. [name] is already cleaned.
|
||||
void applyProfile(String token, {String? name, String? avatar}) {
|
||||
final member = members[token];
|
||||
if (member == null) return;
|
||||
_seen(member);
|
||||
final oldName = member.name;
|
||||
final renamed = name != null && name != oldName;
|
||||
if (renamed) member.name = name;
|
||||
final pictured = avatar != null && member.setAvatar(avatar);
|
||||
if (renamed) {
|
||||
feed.add(FeedMessage(actor: name, action: 'rename', target: oldName, at: now));
|
||||
} else if (pictured) {
|
||||
feed.add(FeedMessage(actor: member.name, action: 'avatar', at: now));
|
||||
}
|
||||
}
|
||||
|
||||
/// Picture bytes of a current member by [EngineMember.avatarHash].
|
||||
Uint8List? avatarFor(String hash) {
|
||||
if (hash.isEmpty) return null;
|
||||
for (final member in members.values) {
|
||||
if (member.avatarHash == hash) return member.avatarBytes;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// A reconnect took over [token]'s member (see `hello.resume`).
|
||||
void applyResume(String token) {
|
||||
final member = members[token];
|
||||
|
||||
@@ -537,4 +537,58 @@ void main() {
|
||||
expect(s.seekBy, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('profile + avatars', () {
|
||||
// Real magic bytes: anything that is not PNG/JPEG/WebP/GIF is dropped.
|
||||
final png = base64Encode([0x89, 0x50, 0x4E, 0x47, ...List<int>.generate(60, (i) => i)]);
|
||||
final other = base64Encode([0xFF, 0xD8, 0xFF, ...List<int>.generate(61, (i) => 255 - i)]);
|
||||
|
||||
test('join keeps bytes, state carries only the hash', () {
|
||||
engine.applyJoin('a', 'alice', png);
|
||||
final hash = engine.members['a']!.avatarHash;
|
||||
expect(hash, hasLength(16));
|
||||
expect(engine.avatarFor(hash), base64Decode(png));
|
||||
final view = engine.broadcast().members.single;
|
||||
expect(view.avatarHash, hash);
|
||||
expect(jsonEncode(engine.broadcast().toJson()), isNot(contains(png)));
|
||||
join('b', 'bob');
|
||||
expect(engine.members['b']!.avatarHash, isEmpty);
|
||||
engine.applyLeave('a');
|
||||
expect(engine.avatarFor(hash), isNull, reason: 'dropped with the member');
|
||||
});
|
||||
|
||||
test('a picture that is not an image is dropped', () {
|
||||
final e = SyncEngine();
|
||||
e.applyJoin('x', 'x', base64Encode(List<int>.generate(64, (i) => i)));
|
||||
expect(e.members['x']!.avatarHash, isEmpty);
|
||||
expect(avatarImageType([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]), ('image', 'webp'));
|
||||
});
|
||||
|
||||
test('rename feeds rename (actor new, target old); picture only feeds avatar', () {
|
||||
engine.applyJoin('a', 'alice', png);
|
||||
engine.feed.drain();
|
||||
engine.applyProfile('a', name: 'bob');
|
||||
var feed = engine.feed.drain().single;
|
||||
expect(feed.action, 'rename');
|
||||
expect(feed.actor, 'bob');
|
||||
expect(feed.target, 'alice');
|
||||
|
||||
engine.applyProfile('a', avatar: other);
|
||||
feed = engine.feed.drain().single;
|
||||
expect(feed.action, 'avatar');
|
||||
expect(feed.actor, 'bob');
|
||||
expect(engine.members['a']!.avatarHash, avatarHashOf(base64Decode(other)));
|
||||
|
||||
engine.applyProfile('a', name: 'carol', avatar: '');
|
||||
feed = engine.feed.drain().single;
|
||||
expect(feed.action, 'rename');
|
||||
expect(engine.members['a']!.avatarHash, isEmpty);
|
||||
expect(engine.members['a']!.avatarBytes, isNull);
|
||||
|
||||
engine.applyProfile('a', name: 'carol', avatar: '');
|
||||
expect(engine.feed.drain(), isEmpty, reason: 'nothing changed');
|
||||
engine.applyProfile('a', avatar: 'not base64!');
|
||||
expect(engine.members['a']!.avatarHash, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -159,6 +159,37 @@ class ServerHub {
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /a/<sid>/<token>/<hash>`: a current member's picture by its
|
||||
/// content hash, for members (valid, unkicked token) only. The bytes for a
|
||||
/// hash never change, so clients may cache them for good.
|
||||
void mountAvatarRoute(RelicApp app, ServerHub hub) {
|
||||
Response status(int code) => Response(code, body: Body.fromString('status $code'));
|
||||
app.get('/a/:sid/:token/:hash', (final req) {
|
||||
final sid = req.pathParameters.raw[#sid] ?? '';
|
||||
final token = req.pathParameters.raw[#token] ?? '';
|
||||
final hash = req.pathParameters.raw[#hash] ?? '';
|
||||
final session = hub.sessions.get(sid);
|
||||
if (session == null) return status(410);
|
||||
switch (session.gate(token)) {
|
||||
case MediaGate.unauthorized:
|
||||
return status(401);
|
||||
case MediaGate.forbidden:
|
||||
return status(403);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
final bytes = hub.engine.avatarFor(hash);
|
||||
final type = bytes == null ? null : avatarImageType(bytes);
|
||||
if (bytes == null || type == null) return status(404);
|
||||
return Response.ok(
|
||||
body: Body.fromData(bytes, mimeType: MimeType(type.$1, type.$2)),
|
||||
headers: Headers.build((h) {
|
||||
h['cache-control'] = ['private, max-age=31536000, immutable'];
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Media names are shown to everyone and must never carry a URL (api_key etc.).
|
||||
/// A URL-looking name is reduced to its last path segment, no query/fragment.
|
||||
String sanitizeMediaName(String name) {
|
||||
@@ -358,6 +389,19 @@ void mountSyncRoute(
|
||||
mediaId: hub.engine.mediaId);
|
||||
}
|
||||
hub.changed();
|
||||
case 'profile':
|
||||
if (token == null || viewer == null) return;
|
||||
final intent = ProfileIntent.fromJson(decoded);
|
||||
final avatar = intent.avatar;
|
||||
if (avatar != null && avatar.length > RelayConstants.maxAvatarBytes) {
|
||||
wsErr(ws, 'rejected: avatar too large');
|
||||
return;
|
||||
}
|
||||
final name = intent.name == null ? null : cleanMemberName(intent.name!);
|
||||
// Chat and kick name the sender by its token's name.
|
||||
if (name != null) viewer.name = name;
|
||||
hub.engine.applyProfile(token, name: name, avatar: avatar);
|
||||
hub.changed();
|
||||
case 'report':
|
||||
if (token == null) return;
|
||||
final report = ReportMessage.fromJson(decoded);
|
||||
@@ -470,7 +514,7 @@ void mountSyncRoute(
|
||||
await close();
|
||||
return;
|
||||
}
|
||||
memberId = hub.engine.applyJoin(token, hello.user.name, hello.user.avatar);
|
||||
memberId = hub.engine.applyJoin(token, session.tokens[token]!.name, hello.user.avatar);
|
||||
}
|
||||
session.tokens[token]!.isHost = hub.isHostKey(hello.hostKey);
|
||||
final activeToken = token;
|
||||
|
||||
@@ -7,15 +7,15 @@ enum MediaGate { ok, gone, unauthorized, forbidden, offline }
|
||||
|
||||
class ViewerToken {
|
||||
final String token;
|
||||
final String name;
|
||||
final String avatar;
|
||||
/// Cleaned ([cleanMemberName]); follows `profile` renames.
|
||||
String name;
|
||||
bool kicked = false;
|
||||
/// Sent the server's host key in hello: the hosting app's own client.
|
||||
bool isHost = false;
|
||||
int bytesServed = 0;
|
||||
DateTime lastSeen;
|
||||
Future<void> Function(String reason)? onKick;
|
||||
ViewerToken({required this.token, required this.name, this.avatar = ''})
|
||||
ViewerToken({required this.token, required this.name})
|
||||
: lastSeen = DateTime.now().toUtc();
|
||||
|
||||
bool isStale(Duration timeout, [DateTime? now]) =>
|
||||
@@ -53,18 +53,14 @@ class Session {
|
||||
|
||||
Session({required this.id, required this.secret});
|
||||
|
||||
/// The avatar is only checked here; the engine keeps the picture.
|
||||
String issueToken(String name, String avatar) {
|
||||
if (avatar.length > RelayConstants.maxAvatarBytes) {
|
||||
throw const AuthException('avatar too large');
|
||||
}
|
||||
final trimmed = name.trim();
|
||||
final cleanName = trimmed.isEmpty
|
||||
? '?'
|
||||
: trimmed.substring(
|
||||
0, trimmed.length.clamp(0, RelayConstants.maxNameLength));
|
||||
final bytes = List<int>.generate(16, (_) => _random.nextInt(256));
|
||||
final token = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
tokens[token] = ViewerToken(token: token, name: cleanName, avatar: avatar);
|
||||
tokens[token] = ViewerToken(token: token, name: cleanMemberName(name));
|
||||
return token;
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ class SyncServer {
|
||||
});
|
||||
mountSessionMediaRoute(_app, sessions);
|
||||
mountRelayRoutes(_app, sessions, () => hub.relay);
|
||||
mountAvatarRoute(_app, hub);
|
||||
mountJellyfinRelayRoute(
|
||||
_app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient());
|
||||
// advertiseHost is only an explicit override; otherwise echo what the client dialled.
|
||||
|
||||
@@ -389,5 +389,93 @@ void main() {
|
||||
await ch.sink.close();
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
test('profile: rename + avatar feed, state carries hash, /a gated', () async {
|
||||
final png = Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, ...List<int>.generate(296, (i) => (i * 7) % 256)]);
|
||||
final ch = WebSocketChannel.connect(
|
||||
Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
|
||||
final messages = <Map<String, Object?>>[];
|
||||
final sub = ch.stream.listen(
|
||||
(m) => messages.add(Map<String, Object?>.from(jsonDecode('$m') as Map)));
|
||||
Future<Map<String, Object?>> waitFor(bool Function(Map<String, Object?>) test) async {
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
for (final m in messages) {
|
||||
if (test(m)) return m;
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
fail('timed out; got $messages');
|
||||
}
|
||||
|
||||
Map<String, Object?> member(Map<String, Object?> state) =>
|
||||
Map<String, Object?>.from((state['members'] as List).single as Map);
|
||||
Future<HttpClientResponse> avatarGet(String token, String hash) async =>
|
||||
(await http.get('127.0.0.1', server.boundPort, '/a/default/$token/$hash')).close();
|
||||
|
||||
try {
|
||||
ch.sink.add(jsonEncode(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 'shh',
|
||||
user: HelloUser(name: 'alice', avatar: base64Encode(png)),
|
||||
media: const HelloMedia(name: 'f.bin', size: 8192, duration: 10.0)).toJson()));
|
||||
final welcome = await waitFor((m) => m['t'] == 'welcome');
|
||||
final token = welcome['token'] as String;
|
||||
final first = member(Map<String, Object?>.from(welcome['state'] as Map));
|
||||
expect(first.containsKey('avatar'), isFalse, reason: 'no base64 in state');
|
||||
final hash = first['avatarHash'] as String;
|
||||
expect(hash, avatarHashOf(png));
|
||||
|
||||
final ok = await avatarGet(token, hash);
|
||||
expect(ok.statusCode, 200);
|
||||
expect(ok.headers.contentType?.mimeType, 'image/png');
|
||||
expect(ok.headers.value('cache-control'), contains('max-age'));
|
||||
expect(await ok.expand((c) => c).toList(), png);
|
||||
final unknown = await avatarGet(token, '0123456789abcdef');
|
||||
expect(unknown.statusCode, 404);
|
||||
await unknown.drain();
|
||||
final noToken = await avatarGet('nope', hash);
|
||||
expect(noToken.statusCode, 401);
|
||||
await noToken.drain();
|
||||
|
||||
// Rename: feed rename (actor new, target old); chat uses the new name.
|
||||
messages.clear();
|
||||
ch.sink.add(jsonEncode(const ProfileIntent(name: ' bob ').toJson()));
|
||||
final rename = await waitFor((m) => m['t'] == 'feed' && m['action'] == 'rename');
|
||||
expect(rename['actor'], 'bob');
|
||||
expect(rename['target'], 'alice');
|
||||
final renamed = await waitFor((m) => m['t'] == 'state' && member(m)['name'] == 'bob');
|
||||
expect(member(renamed)['avatarHash'], hash, reason: 'picture unchanged');
|
||||
ch.sink.add(jsonEncode(const ChatMessage(text: 'hi').toJson()));
|
||||
expect((await waitFor((m) => m['t'] == 'chat'))['from'], 'bob');
|
||||
|
||||
// Picture only: feed avatar; removing it drops the hash and the bytes.
|
||||
messages.clear();
|
||||
ch.sink.add(jsonEncode(const ProfileIntent(avatar: '').toJson()));
|
||||
final pic = await waitFor((m) => m['t'] == 'feed' && m['action'] == 'avatar');
|
||||
expect(pic['actor'], 'bob');
|
||||
await waitFor((m) => m['t'] == 'state' && !member(m).containsKey('avatarHash'));
|
||||
final gone = await avatarGet(token, hash);
|
||||
expect(gone.statusCode, 404);
|
||||
await gone.drain();
|
||||
|
||||
// Too large: rejected, nothing changes.
|
||||
messages.clear();
|
||||
ch.sink.add(jsonEncode(
|
||||
ProfileIntent(avatar: 'x' * (RelayConstants.maxAvatarBytes + 1)).toJson()));
|
||||
final err = await waitFor((m) => m['t'] == 'err');
|
||||
expect(err['message'], 'rejected: avatar too large');
|
||||
|
||||
await server.session.kick(token);
|
||||
final kicked = await avatarGet(token, hash);
|
||||
expect(kicked.statusCode, 403);
|
||||
await kicked.drain();
|
||||
} finally {
|
||||
await sub.cancel();
|
||||
try {
|
||||
await ch.sink.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -47,7 +47,7 @@ void main() {
|
||||
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));
|
||||
expect(s.tokens, hasLength(1));
|
||||
});
|
||||
|
||||
test('names truncated to cap, blank becomes ?', () {
|
||||
@@ -80,8 +80,21 @@ void main() {
|
||||
});
|
||||
|
||||
group('core welcome + proto', () {
|
||||
test('proto 8: relayed media', () {
|
||||
expect(RelayConstants.protoVersion, 8);
|
||||
test('proto 9: avatars by hash, profile intent', () {
|
||||
expect(RelayConstants.protoVersion, 9);
|
||||
const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false);
|
||||
expect(v.toJson().containsKey('avatarHash'), isFalse);
|
||||
expect(v.toJson().containsKey('avatar'), isFalse);
|
||||
const h = MemberView(
|
||||
id: 'm1', name: 'a', avatarHash: 'abc', ready: false, inSet: false, buffered: 0, stale: false);
|
||||
expect(MemberView.fromJson(Map<String, Object?>.from(h.toJson())).avatarHash, 'abc');
|
||||
expect(const ProfileIntent(name: 'b').toJson(), {'t': 'profile', 'name': 'b'});
|
||||
final p = ProfileIntent.fromJson({'t': 'profile', 'avatar': ''});
|
||||
expect(p.name, isNull);
|
||||
expect(p.avatar, '');
|
||||
});
|
||||
|
||||
test('relayed media', () {
|
||||
const m = HelloMedia(name: 'a', size: 1, duration: 0, relay: MediaRelay(source: 'm1', live: true));
|
||||
final back = HelloMedia.fromJson(Map<String, Object?>.from(m.toJson()));
|
||||
expect(back.relay!.source, 'm1');
|
||||
|
||||
Reference in new issue
Block a user