forked from Foxyz/sync_replay_project
Protocol 11: sit out replaces kicking; nudge
Anyone can sit anyone out (host included); only you bring yourself back. Out members stay in chat and the party, never block or count, and load no media; a card with the live position and Join back (Space) replaces the video. The mini player's x sits you out; optionally idle does too. Nudge: the server forwards it to the target only (3/s, 20/min per sender); the target hears the nudge sound, gets a toast and a taskbar attention request. Kick, its messages, errors and dialog are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dbfd351ee4
commit
8b81da4069
46 files changed
+855
-599
No files matched your search
@@ -101,7 +101,7 @@ class AppConfig {
|
||||
/// client dialled.
|
||||
final String advertiseHost;
|
||||
/// Secret the hosting app's own client sends in hello to be marked host
|
||||
/// (unkickable). Empty means no member is the host (dedicated server).
|
||||
/// (`members[].host`). Empty means no member is the host (dedicated server).
|
||||
final String hostKey;
|
||||
/// Optional login for relaying Jellyfin media to guests; null = no relay.
|
||||
final JellyfinCredential? jellyfin;
|
||||
|
||||
@@ -50,4 +50,8 @@ abstract class RelayConstants {
|
||||
static const int maxBannerBytes = 768 * 1024;
|
||||
static const int maxNameLength = 32;
|
||||
static const int maxChatLength = 500;
|
||||
/// Nudges one member may send: at most this many per second and per
|
||||
/// minute; the server drops the rest silently.
|
||||
static const int nudgesPerSec = 3;
|
||||
static const int nudgesPerMin = 20;
|
||||
}
|
||||
@@ -288,31 +288,46 @@ class WelcomeMessage {
|
||||
}
|
||||
}
|
||||
|
||||
class KickedMessage {
|
||||
final String reason;
|
||||
const KickedMessage({this.reason = ''});
|
||||
/// Sits [target] (a member id) out, or with [out] false brings oneself
|
||||
/// back. Out: connected, but not in the ready gate and not playing.
|
||||
class OutIntent {
|
||||
final String target;
|
||||
final bool out;
|
||||
const OutIntent({required this.target, required this.out});
|
||||
|
||||
Map<String, Object?> toJson() => {'t': 'kicked', 'reason': reason};
|
||||
Map<String, Object?> toJson() => {'t': 'out', 'target': target, 'out': out};
|
||||
|
||||
factory KickedMessage.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'kicked') throw const ProtocolException('not a kicked message');
|
||||
return KickedMessage(reason: (json['reason'] as String?) ?? '');
|
||||
factory OutIntent.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'out') throw const ProtocolException('not an out intent');
|
||||
return OutIntent(target: (json['target'] as String?) ?? '', out: (json['out'] as bool?) ?? true);
|
||||
}
|
||||
}
|
||||
|
||||
class KickRequest {
|
||||
/// Client to server: poke [target] (a member id). The server passes it on
|
||||
/// to that member only, as a [NudgeNotice].
|
||||
class NudgeIntent {
|
||||
final String target;
|
||||
final String reason;
|
||||
const KickRequest({required this.target, this.reason = ''});
|
||||
const NudgeIntent({required this.target});
|
||||
|
||||
Map<String, Object?> toJson() => {'t': 'kick', 'target': target, 'reason': reason};
|
||||
Map<String, Object?> toJson() => {'t': 'nudge', 'target': target};
|
||||
|
||||
factory KickRequest.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'kick') throw const ProtocolException('not a kick request');
|
||||
return KickRequest(
|
||||
target: (json['target'] as String?) ?? '',
|
||||
reason: (json['reason'] as String?) ?? '',
|
||||
);
|
||||
factory NudgeIntent.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'nudge') throw const ProtocolException('not a nudge intent');
|
||||
return NudgeIntent(target: (json['target'] as String?) ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
/// Server to the nudged member: who poked it ([from] member id, [name]).
|
||||
class NudgeNotice {
|
||||
final String from;
|
||||
final String name;
|
||||
const NudgeNotice({required this.from, required this.name});
|
||||
|
||||
Map<String, Object?> toJson() => {'t': 'nudge', 'from': from, 'name': name};
|
||||
|
||||
factory NudgeNotice.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'nudge') throw const ProtocolException('not a nudge notice');
|
||||
return NudgeNotice(from: (json['from'] as String?) ?? '', name: (json['name'] as String?) ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,6 +551,8 @@ class MemberView {
|
||||
final Map<String, bool> jf;
|
||||
/// The hosting app's own member (sent the server's host key).
|
||||
final bool host;
|
||||
/// Sitting out: connected, outside the ready gate, not playing.
|
||||
final bool out;
|
||||
const MemberView({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -546,6 +563,7 @@ class MemberView {
|
||||
required this.stale,
|
||||
this.jf = const {},
|
||||
this.host = false,
|
||||
this.out = false,
|
||||
});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
@@ -558,6 +576,7 @@ class MemberView {
|
||||
'stale': stale,
|
||||
if (jf.isNotEmpty) 'jf': jf,
|
||||
if (host) 'host': true,
|
||||
if (out) 'out': true,
|
||||
};
|
||||
|
||||
factory MemberView.fromJson(Map<String, Object?> json) => MemberView(
|
||||
@@ -570,6 +589,7 @@ class MemberView {
|
||||
stale: (json['stale'] as bool?) ?? false,
|
||||
jf: jfAccessFromJson(json['jf']) ?? const {},
|
||||
host: json['host'] == true,
|
||||
out: json['out'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ class EngineMember {
|
||||
Map<String, bool> jf = const {};
|
||||
/// The hosting app's own member (set by the server from the host key).
|
||||
bool host = false;
|
||||
/// Sitting out: never in the set, never ready, its buffer ignored.
|
||||
bool out = false;
|
||||
DateTime lastSeen;
|
||||
EngineMember({
|
||||
required this.id,
|
||||
@@ -94,6 +96,7 @@ class EngineMember {
|
||||
stale: stale,
|
||||
jf: jf,
|
||||
host: host,
|
||||
out: out,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -193,7 +196,8 @@ class SyncEngine {
|
||||
void _seen(EngineMember member) {
|
||||
final wasStale = isStale(member);
|
||||
member.lastSeen = now;
|
||||
if (!member.inSet &&
|
||||
if (!member.out &&
|
||||
!member.inSet &&
|
||||
(member.ready || (wasStale && transport != Transport.playing))) {
|
||||
member.inSet = true;
|
||||
}
|
||||
@@ -301,6 +305,8 @@ class SyncEngine {
|
||||
final member = members[token];
|
||||
if (member == null) return;
|
||||
_seen(member);
|
||||
// Sitting out: back first ([applyOut]), then ready.
|
||||
if (member.out) return;
|
||||
if (member.ready == ready && member.inSet) {
|
||||
evaluate();
|
||||
return;
|
||||
@@ -320,12 +326,13 @@ class SyncEngine {
|
||||
final actorName = actor?.name ?? '?';
|
||||
if (target == 'all') {
|
||||
for (final member in members.values) {
|
||||
if (member.out) continue;
|
||||
member.ready = ready;
|
||||
if (ready) member.inSet = true;
|
||||
}
|
||||
} else {
|
||||
final member = byId(target);
|
||||
if (member == null) return;
|
||||
if (member == null || member.out) return;
|
||||
member.ready = ready;
|
||||
if (ready) member.inSet = true;
|
||||
feed.add(FeedMessage(
|
||||
@@ -343,6 +350,28 @@ class SyncEngine {
|
||||
evaluate();
|
||||
}
|
||||
|
||||
/// Sits the member [targetId] out ([out] true; anyone may, the host
|
||||
/// included), or brings [actorToken]'s own member back ([out] false;
|
||||
/// only oneself). Out: not ready, not in the set, so it never blocks.
|
||||
/// Back: in the set like a fresh join (unless playing), still unready:
|
||||
/// its own `ready` follows once it has buffered.
|
||||
void applyOut(String actorToken, String targetId, bool out) {
|
||||
final actor = members[actorToken];
|
||||
final member = byId(targetId);
|
||||
if (actor == null || member == null) return;
|
||||
_seen(actor);
|
||||
final self = identical(actor, member);
|
||||
if (!out && !self) return;
|
||||
if (member.out == out) return;
|
||||
member.out = out;
|
||||
member.ready = false;
|
||||
member.buffered = 0;
|
||||
member.inSet = !out && transport != Transport.playing;
|
||||
feed.add(FeedMessage(
|
||||
actor: actor.name, action: out ? 'out' : 'back', target: self ? null : member.name, at: now));
|
||||
evaluate();
|
||||
}
|
||||
|
||||
void applySeek(String token, double seekPosition, {int seq = 0}) {
|
||||
final member = members[token];
|
||||
if (member == null) return;
|
||||
@@ -379,7 +408,7 @@ class SyncEngine {
|
||||
// (a stale member rejoins the set with its next frame if still ready).
|
||||
final t = now;
|
||||
for (final member in members.values) {
|
||||
member.inSet = !isStale(member, t);
|
||||
member.inSet = !member.out && !isStale(member, t);
|
||||
member.buffered = 0;
|
||||
}
|
||||
feed.add(FeedMessage(actor: actor?.name ?? '?', action: 'swap', at: now));
|
||||
@@ -393,7 +422,7 @@ class SyncEngine {
|
||||
if (!reportPosition.isFinite) return;
|
||||
_seen(member);
|
||||
if (jf != null) member.jf = Map.unmodifiable(jf);
|
||||
member.buffered = !buffered.isFinite || buffered < 0 ? 0 : buffered;
|
||||
member.buffered = member.out || !buffered.isFinite || buffered < 0 ? 0 : buffered;
|
||||
final m = media;
|
||||
// First report of the length wins; only for the media the client has open.
|
||||
if (m != null && m.duration <= 0 && mediaId == this.mediaId &&
|
||||
|
||||
@@ -36,6 +36,55 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('sitting out', () {
|
||||
test('an out member never blocks, is never ready, its buffer is ignored', () {
|
||||
join('a', 'alice');
|
||||
final bob = join('b', 'bob');
|
||||
engine.applyReady('a', true);
|
||||
buffer('a', 4.0);
|
||||
expect(engine.transport, Transport.held, reason: 'bob is in the set');
|
||||
engine.applyOut('a', bob, true);
|
||||
final b = engine.members['b']!;
|
||||
expect([b.out, b.inSet, b.ready], [true, false, false]);
|
||||
expect(engine.transport, Transport.playing);
|
||||
final feed = engine.feed.drain().lastWhere((f) => f.action == 'out');
|
||||
expect([feed.actor, feed.target], ['alice', 'bob']);
|
||||
expect(engine.broadcast().members.last.toJson()['out'], isTrue);
|
||||
expect(engine.broadcast().members.first.toJson().containsKey('out'), isFalse);
|
||||
// Ready, force, swap and reports leave it out.
|
||||
engine.applyReady('b', true);
|
||||
engine.applyForce('a', 'all', true);
|
||||
buffer('b', 9);
|
||||
engine.applySwap('a', const HelloMedia(name: 'n', size: 1, duration: 0, url: 'u'));
|
||||
expect([b.ready, b.inSet, b.buffered], [false, false, 0]);
|
||||
expect(engine.members['a']!.inSet, isTrue);
|
||||
});
|
||||
|
||||
test('only oneself comes back: in the set unready, ready follows', () {
|
||||
final alice = join('a', 'alice');
|
||||
final bob = join('b', 'bob');
|
||||
engine.applyOut('b', bob, true);
|
||||
expect(engine.feed.drain().last.target, isNull, reason: 'sat out by itself');
|
||||
engine.applyReady('a', true);
|
||||
buffer('a', 4.0);
|
||||
expect(engine.transport, Transport.playing);
|
||||
engine.applyOut('a', bob, false);
|
||||
expect(engine.members['b']!.out, isTrue, reason: 'others cannot bring bob back');
|
||||
engine.applyOut('b', bob, false);
|
||||
final b = engine.members['b']!;
|
||||
expect([b.out, b.inSet, b.ready], [false, false, false], reason: 'playing: in from its ready');
|
||||
expect(engine.transport, Transport.playing);
|
||||
engine.applyReady('b', true);
|
||||
expect(engine.transport, Transport.held, reason: 'now the gate waits for bob');
|
||||
buffer('b', 4.0);
|
||||
expect(engine.transport, Transport.playing);
|
||||
// The host can be sat out like anyone.
|
||||
engine.members['a']!.host = true;
|
||||
engine.applyOut('b', alice, true);
|
||||
expect(engine.members['a']!.out, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('membership + gate', () {
|
||||
test('join while held lands in the set, playback stays held', () {
|
||||
join('a', 'alice');
|
||||
|
||||
@@ -53,8 +53,6 @@ void mountJellyfinRelayRoute(
|
||||
switch (session.gate(token)) {
|
||||
case MediaGate.ok:
|
||||
break;
|
||||
case MediaGate.forbidden:
|
||||
return _plain(403);
|
||||
default:
|
||||
return _plain(401);
|
||||
}
|
||||
|
||||
@@ -58,8 +58,6 @@ MediaResolution resolveMediaRequest({
|
||||
return MediaResolution(status: 410, headers: base(contentType), start: 0, end: 0);
|
||||
case MediaGate.unauthorized:
|
||||
return MediaResolution(status: 401, headers: base(contentType), start: 0, end: 0);
|
||||
case MediaGate.forbidden:
|
||||
return MediaResolution(status: 403, headers: base(contentType), start: 0, end: 0);
|
||||
case MediaGate.offline:
|
||||
return MediaResolution(
|
||||
status: 503,
|
||||
|
||||
@@ -139,13 +139,6 @@ class ActiveRelay {
|
||||
if (ch != null) unawaited(_closeNotSource(ch));
|
||||
}
|
||||
|
||||
/// Source member gone for good (kicked): stop its channel.
|
||||
void dropChannel() {
|
||||
final ch = _channel;
|
||||
_channel = null;
|
||||
if (ch != null) unawaited(_closeNotSource(ch));
|
||||
}
|
||||
|
||||
void _attachChannel(RelicWebSocket ws, List<int> sizes) {
|
||||
final old = _channel;
|
||||
if (old != null && !identical(old, ws)) unawaited(old.tryClose());
|
||||
@@ -290,8 +283,6 @@ void mountRelayRoutes(
|
||||
switch (session.gate(token)) {
|
||||
case MediaGate.ok:
|
||||
break;
|
||||
case MediaGate.forbidden:
|
||||
return _plain(403);
|
||||
default:
|
||||
return _plain(401);
|
||||
}
|
||||
|
||||
@@ -66,11 +66,9 @@ class ServerHub {
|
||||
bool isHostKey(String key) => hostKey.isNotEmpty && key == hostKey;
|
||||
|
||||
/// The member a reconnecting client may take over with its old token:
|
||||
/// still known and not kicked.
|
||||
/// still known.
|
||||
EngineMember? resumable(String token) {
|
||||
if (token.isEmpty) return null;
|
||||
final viewer = session.tokens[token];
|
||||
if (viewer == null || viewer.kicked) return null;
|
||||
if (token.isEmpty || !session.tokens.containsKey(token)) return null;
|
||||
return engine.members[token];
|
||||
}
|
||||
|
||||
@@ -150,24 +148,25 @@ class ServerHub {
|
||||
|
||||
void forgetSocket(String token) {
|
||||
engine.applyLeave(token);
|
||||
final viewer = session.tokens[token];
|
||||
if (viewer != null && !viewer.kicked) session.tokens.remove(token);
|
||||
session.tokens.remove(token);
|
||||
sockets.remove(token);
|
||||
changed();
|
||||
}
|
||||
|
||||
Future<void> kickById(String targetId, String reason) async {
|
||||
final member = engine.byId(targetId);
|
||||
if (member == null) throw const AuthException('unknown kick target');
|
||||
if (session.tokens[member.token]?.isHost ?? false) {
|
||||
throw const AuthException("can't kick the host");
|
||||
}
|
||||
await session.kick(member.token, reason: reason);
|
||||
/// Passes a nudge from [fromToken] to the member [targetId] only;
|
||||
/// over the sender's rate cap, to itself or to nobody it is dropped.
|
||||
void nudge(String fromToken, String targetId) {
|
||||
final from = engine.members[fromToken];
|
||||
final target = engine.byId(targetId);
|
||||
final viewer = session.tokens[fromToken];
|
||||
if (from == null || target == null || viewer == null || identical(from, target)) return;
|
||||
if (!viewer.takeNudge(engine.now)) return;
|
||||
sockets[target.token]?.trySendText(jsonEncode(NudgeNotice(from: from.id, name: from.name).toJson()));
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /a/<sid>/<token>/<hash>`: a current member's picture by its
|
||||
/// content hash, for members (valid, unkicked token) only. The bytes for a
|
||||
/// content hash, for members (valid token) only. The bytes for a
|
||||
/// hash never change, so clients may cache them for good.
|
||||
void mountAvatarRoute(RelicApp app, ServerHub hub) =>
|
||||
_mountPictureRoute(app, hub, 'a', hub.engine.avatarFor);
|
||||
@@ -188,8 +187,6 @@ void _mountPictureRoute(RelicApp app, ServerHub hub, String prefix, Uint8List? F
|
||||
switch (session.gate(token)) {
|
||||
case MediaGate.unauthorized:
|
||||
return status(401);
|
||||
case MediaGate.forbidden:
|
||||
return status(403);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -415,7 +412,7 @@ void mountSyncRoute(
|
||||
return;
|
||||
}
|
||||
final name = intent.name == null ? null : cleanMemberName(intent.name!);
|
||||
// Chat and kick name the sender by its token's name.
|
||||
// Chat names the sender by its token's name.
|
||||
if (name != null) viewer.name = name;
|
||||
hub.engine.applyProfile(token, name: name, avatar: avatar);
|
||||
hub.changed();
|
||||
@@ -441,22 +438,14 @@ void mountSyncRoute(
|
||||
case 'chat':
|
||||
final chat = ChatMessage.fromJson(decoded);
|
||||
hub.broadcastChat(viewer?.name ?? '?', chat.text);
|
||||
case 'kick':
|
||||
final kickReq = KickRequest.fromJson(decoded);
|
||||
final member = hub.engine.byId(kickReq.target);
|
||||
if (member == null) {
|
||||
wsErr(ws, 'unknown kick target');
|
||||
return;
|
||||
}
|
||||
if (session.tokens[member.token]?.isHost ?? false) {
|
||||
wsErr(ws, "can't kick the host");
|
||||
return;
|
||||
}
|
||||
final by = viewer?.name ?? '?';
|
||||
unawaited(hub.session.kick(member.token,
|
||||
reason: kickReq.reason.isEmpty
|
||||
? 'kicked by $by'
|
||||
: kickReq.reason));
|
||||
case 'out':
|
||||
if (token == null) return;
|
||||
final intent = OutIntent.fromJson(decoded);
|
||||
hub.engine.applyOut(token, intent.target, intent.out);
|
||||
hub.changed();
|
||||
case 'nudge':
|
||||
if (token == null) return;
|
||||
hub.nudge(token, NudgeIntent.fromJson(decoded).target);
|
||||
}
|
||||
} on ProtocolException catch (e) {
|
||||
wsErr(ws, 'bad message: ${e.message}');
|
||||
@@ -544,13 +533,6 @@ void mountSyncRoute(
|
||||
hub.engine.members[token]?.host = isHost;
|
||||
final activeToken = token;
|
||||
hub.sockets[activeToken] = ws;
|
||||
session.tokens[activeToken]!.onKick = (reason) async {
|
||||
if (hub.relay?.sourceToken == activeToken) hub.relay!.dropChannel();
|
||||
ws.trySendText(jsonEncode(KickedMessage(reason: reason).toJson()));
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
await ws.tryClose();
|
||||
hub.forgetSocketOf(activeToken, ws);
|
||||
};
|
||||
final media = session.media;
|
||||
final relay = hub.relayAvailable;
|
||||
final relayed = hub.relay;
|
||||
|
||||
@@ -3,21 +3,31 @@ import 'dart:math';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
enum MediaGate { ok, gone, unauthorized, forbidden, offline }
|
||||
enum MediaGate { ok, gone, unauthorized, offline }
|
||||
|
||||
class ViewerToken {
|
||||
final String token;
|
||||
/// 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;
|
||||
/// When this member's recent nudges went out, for the rate cap.
|
||||
final List<DateTime> nudges = [];
|
||||
ViewerToken({required this.token, required this.name})
|
||||
: lastSeen = DateTime.now().toUtc();
|
||||
|
||||
/// Takes one nudge at [now] if under the caps
|
||||
/// ([RelayConstants.nudgesPerSec] / [RelayConstants.nudgesPerMin]).
|
||||
bool takeNudge(DateTime now) {
|
||||
nudges.removeWhere((t) => now.difference(t) >= const Duration(minutes: 1));
|
||||
final lastSec = nudges.where((t) => now.difference(t) < const Duration(seconds: 1)).length;
|
||||
if (nudges.length >= RelayConstants.nudgesPerMin || lastSec >= RelayConstants.nudgesPerSec) return false;
|
||||
nudges.add(now);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool isStale(Duration timeout, [DateTime? now]) =>
|
||||
(now ?? DateTime.now().toUtc()).difference(lastSeen) > timeout;
|
||||
|
||||
@@ -66,17 +76,9 @@ class Session {
|
||||
|
||||
MediaGate gate(String? token) {
|
||||
if (token == null || !tokens.containsKey(token)) return MediaGate.unauthorized;
|
||||
if (tokens[token]!.kicked) return MediaGate.forbidden;
|
||||
return MediaGate.ok;
|
||||
}
|
||||
|
||||
Future<void> kick(String token, {String reason = ''}) async {
|
||||
final viewer = tokens[token];
|
||||
if (viewer == null) throw const AuthException('unknown token');
|
||||
viewer.kicked = true;
|
||||
await viewer.onKick?.call(reason);
|
||||
}
|
||||
|
||||
void addBytes(String? token, int n) {
|
||||
if (token != null) tokens[token]?.bytesServed += n;
|
||||
}
|
||||
|
||||
@@ -123,29 +123,56 @@ void main() {
|
||||
await a.ws.close();
|
||||
});
|
||||
|
||||
test('host (hostKey) cannot be kicked; loopback without it can', () async {
|
||||
test('anyone sits anyone out (host too); nudges reach only the target, capped', () async {
|
||||
await server.stop();
|
||||
server = SyncServer(AppConfig(port: 0, secret: '', name: 't', hostKey: 'hk'));
|
||||
await server.start();
|
||||
final host =
|
||||
await _Peer.join('127.0.0.1', server.boundPort, 'host', hostKey: 'hk');
|
||||
final friend = await _Peer.join('127.0.0.1', server.boundPort, 'friend');
|
||||
final guesser =
|
||||
await _Peer.join('127.0.0.1', server.boundPort, 'guesser', hostKey: 'nope');
|
||||
final third = await _Peer.join('127.0.0.1', server.boundPort, 'third');
|
||||
String idOf(String name) =>
|
||||
server.hub.engine.members.values.firstWhere((m) => m.name == name).id;
|
||||
friend.send(jsonEncode(KickRequest(target: idOf('host')).toJson()));
|
||||
final err = await friend.next('err');
|
||||
expect(err['message'], "can't kick the host");
|
||||
expect(host.closed, isFalse);
|
||||
await expectLater(
|
||||
server.hub.kickById(idOf('host'), 'x'), throwsA(isA<AuthException>()));
|
||||
// Same machine, no key (or a wrong one): an ordinary member.
|
||||
host.send(jsonEncode(KickRequest(target: idOf('guesser')).toJson()));
|
||||
await guesser.next('kicked');
|
||||
await server.hub.kickById(idOf('friend'), 'x');
|
||||
await friend.next('kicked');
|
||||
EngineMember of(String name) =>
|
||||
server.hub.engine.members.values.firstWhere((m) => m.name == name);
|
||||
friend.send(jsonEncode(OutIntent(target: idOf('host'), out: true).toJson()));
|
||||
var feed = await friend.next('feed');
|
||||
while (feed['action'] != 'out') {
|
||||
feed = await friend.next('feed');
|
||||
}
|
||||
expect([feed['actor'], feed['target']], ['friend', 'host']);
|
||||
expect(of('host').out, isTrue);
|
||||
expect(of('host').inSet, isFalse);
|
||||
// Only oneself comes back.
|
||||
friend.send(jsonEncode(OutIntent(target: idOf('host'), out: false).toJson()));
|
||||
host.send(jsonEncode(OutIntent(target: idOf('host'), out: false).toJson()));
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
expect(of('host').out, isFalse);
|
||||
|
||||
// 5 at once: 3 pass (per second), only to the target.
|
||||
for (var i = 0; i < 5; i++) {
|
||||
friend.send(jsonEncode(NudgeIntent(target: idOf('host')).toJson()));
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
final got = host.msgs.where((m) => m['t'] == 'nudge').toList();
|
||||
expect(got, hasLength(3));
|
||||
expect(got.first, {'t': 'nudge', 'from': idOf('friend'), 'name': 'friend'});
|
||||
expect(third.msgs.where((m) => m['t'] == 'nudge'), isEmpty);
|
||||
expect(friend.msgs.where((m) => m['t'] == 'nudge'), isEmpty);
|
||||
await host.ws.close();
|
||||
await friend.ws.close();
|
||||
await third.ws.close();
|
||||
});
|
||||
|
||||
test('nudge cap: 20 per minute', () {
|
||||
final v = ViewerToken(token: 't', name: 'n');
|
||||
final t0 = DateTime.utc(2026);
|
||||
var passed = 0;
|
||||
for (var i = 0; i < 30; i++) {
|
||||
if (v.takeNudge(t0.add(Duration(milliseconds: 500 * i)))) passed++;
|
||||
}
|
||||
expect(passed, 20);
|
||||
expect(v.takeNudge(t0.add(const Duration(seconds: 61))), isTrue);
|
||||
});
|
||||
|
||||
test('state marks the host member for everyone', () async {
|
||||
|
||||
@@ -54,9 +54,6 @@ void main() {
|
||||
resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.unauthorized)
|
||||
.status,
|
||||
401);
|
||||
expect(
|
||||
resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.forbidden).status,
|
||||
403);
|
||||
final off =
|
||||
resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.offline);
|
||||
expect(off.status, 503);
|
||||
|
||||
@@ -133,189 +133,6 @@ void main() {
|
||||
await resp.drain();
|
||||
});
|
||||
|
||||
test('kick: WS drops and media goes 403', () async {
|
||||
final ch = WebSocketChannel.connect(
|
||||
Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
|
||||
final firstCompleter = Completer<String>();
|
||||
var wsClosed = false;
|
||||
final sub = ch.stream.listen(
|
||||
(m) {
|
||||
if (!firstCompleter.isCompleted) firstCompleter.complete('$m');
|
||||
},
|
||||
onDone: () => wsClosed = true,
|
||||
);
|
||||
final msg = HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 'shh',
|
||||
user: const HelloUser(name: 'mallory'),
|
||||
media:
|
||||
const HelloMedia(name: 'f.bin', size: 8192, duration: 10.0)).toJson();
|
||||
ch.sink.add(jsonEncode(msg));
|
||||
final first =
|
||||
await firstCompleter.future.timeout(const Duration(seconds: 5));
|
||||
final token = (jsonDecode(first) as Map)['token'] as String;
|
||||
|
||||
await server.session.kick(token);
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (!wsClosed && DateTime.now().isBefore(deadline)) {
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
expect(wsClosed, isTrue);
|
||||
try {
|
||||
await sub.cancel();
|
||||
} catch (_) {}
|
||||
try {
|
||||
await ch.sink.close();
|
||||
} catch (_) {}
|
||||
|
||||
final name = file.path.split(Platform.pathSeparator).last;
|
||||
final resp = await mediaGet('/m/default/$token/$name');
|
||||
expect(resp.statusCode, 403);
|
||||
await resp.drain();
|
||||
});
|
||||
|
||||
test('kick delivers reason then drops; instant rejoin works', () async {
|
||||
final ch = WebSocketChannel.connect(
|
||||
Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
|
||||
final messages = <String>[];
|
||||
var wsClosed = false;
|
||||
final sub = ch.stream.listen(
|
||||
(m) => messages.add('$m'),
|
||||
onDone: () => wsClosed = true,
|
||||
);
|
||||
HelloMessage helloFor(String who) => HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 'shh',
|
||||
user: HelloUser(name: who),
|
||||
media: const HelloMedia(
|
||||
name: 'f.bin', size: 8192, duration: 10.0));
|
||||
ch.sink.add(jsonEncode(helloFor(' sleepy').toJson()));
|
||||
Map<String, Object?>? welcome;
|
||||
final welcomeDeadline = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (DateTime.now().isBefore(welcomeDeadline)) {
|
||||
for (final m in messages) {
|
||||
final decoded = jsonDecode(m) as Map;
|
||||
if (decoded['t'] == 'welcome') {
|
||||
welcome = Map<String, Object?>.from(decoded);
|
||||
}
|
||||
}
|
||||
if (welcome != null) break;
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
expect(welcome, isNotNull);
|
||||
final token = welcome!['token'] as String;
|
||||
|
||||
await server.session.kick(token, reason: 'idle too long');
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
||||
Map<String, Object?>? kicked;
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
for (final m in messages.skip(1)) {
|
||||
final decoded = jsonDecode(m) as Map;
|
||||
if (decoded['t'] == 'kicked') {
|
||||
kicked = Map<String, Object?>.from(decoded);
|
||||
}
|
||||
}
|
||||
if (wsClosed && kicked != null) break;
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
expect(wsClosed, isTrue);
|
||||
expect(kicked, isNotNull);
|
||||
expect(kicked!['reason'], 'idle too long');
|
||||
try {
|
||||
await sub.cancel();
|
||||
} catch (_) {}
|
||||
try {
|
||||
await ch.sink.close();
|
||||
} catch (_) {}
|
||||
|
||||
final rejoin = await hello('shh');
|
||||
final decoded = jsonDecode(rejoin) as Map;
|
||||
expect(decoded['t'], 'welcome');
|
||||
final token2 = decoded['token'] as String;
|
||||
expect(token2, isNot(token));
|
||||
final name = file.path.split(Platform.pathSeparator).last;
|
||||
final resp = await mediaGet('/m/default/$token2/$name');
|
||||
expect(resp.statusCode, 200);
|
||||
await resp.drain();
|
||||
});
|
||||
|
||||
test('kick via socket: peer kicked, unknown target errors', () async {
|
||||
Future<(WebSocketChannel, String, String, List<String>)> join(String who,
|
||||
[String host = '127.0.0.1']) async {
|
||||
final ch = WebSocketChannel.connect(
|
||||
Uri.parse('ws://$host:${server.boundPort}/sync'));
|
||||
final messages = <String>[];
|
||||
ch.stream.listen((m) => messages.add('$m'));
|
||||
final msg = HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 'shh',
|
||||
user: HelloUser(name: who),
|
||||
media: const HelloMedia(
|
||||
name: 'f.bin', size: 8192, duration: 10.0)).toJson();
|
||||
ch.sink.add(jsonEncode(msg));
|
||||
Map<String, Object?>? welcome;
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
for (final m in messages) {
|
||||
final decoded = jsonDecode(m) as Map;
|
||||
if (decoded['t'] == 'welcome') {
|
||||
welcome = Map<String, Object?>.from(decoded);
|
||||
}
|
||||
}
|
||||
if (welcome != null) break;
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
expect(welcome, isNotNull,
|
||||
reason: 'no welcome for $who: $messages');
|
||||
final w = welcome!;
|
||||
return (ch, w['token'] as String, w['memberId'] as String, messages);
|
||||
}
|
||||
|
||||
Map<String, Object?>? findType(List<String> messages, String type) {
|
||||
for (final m in messages) {
|
||||
final decoded = jsonDecode(m) as Map;
|
||||
if (decoded['t'] == type) return Map<String, Object?>.from(decoded);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>?> waitType(
|
||||
List<String> messages, String type, Duration timeout) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
final found = findType(messages, type);
|
||||
if (found != null) return found;
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
return findType(messages, type);
|
||||
}
|
||||
|
||||
final (a, _, _, messagesA) = await join('alice');
|
||||
final (b, tokenB, idB, messagesB) = await join('bob');
|
||||
try {
|
||||
a.sink.add(jsonEncode(
|
||||
KickRequest(target: idB, reason: 'afk?').toJson()));
|
||||
final kicked = await waitType(
|
||||
messagesB, 'kicked', const Duration(seconds: 5));
|
||||
expect(kicked, isNotNull);
|
||||
expect(kicked!['reason'], 'afk?');
|
||||
expect(server.session.gate(tokenB), MediaGate.forbidden);
|
||||
|
||||
a.sink.add(
|
||||
jsonEncode(const KickRequest(target: 'm99').toJson()));
|
||||
final err = await waitType(
|
||||
messagesA, 'err', const Duration(seconds: 5));
|
||||
expect(err, isNotNull);
|
||||
} finally {
|
||||
try {
|
||||
await a.sink.close();
|
||||
} catch (_) {}
|
||||
try {
|
||||
await b.sink.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
test('silent socket dropped and member removed', () async {
|
||||
final ch = WebSocketChannel.connect(
|
||||
Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
|
||||
@@ -557,11 +374,6 @@ void main() {
|
||||
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 {
|
||||
|
||||
@@ -21,22 +21,6 @@ void main() {
|
||||
expect(s.gate(t), MediaGate.ok);
|
||||
});
|
||||
|
||||
test('kick unknown throws, kick known forbids + drops', () async {
|
||||
final s = Session(id: 'default', secret: 'shh');
|
||||
expect(() => s.kick('nope'), throwsA(isA<AuthException>()));
|
||||
final t = s.issueToken('mallory', '');
|
||||
var dropped = false;
|
||||
String? gotReason;
|
||||
s.tokens[t]!.onKick = (reason) async {
|
||||
dropped = true;
|
||||
gotReason = reason;
|
||||
};
|
||||
await s.kick(t, reason: 'idle');
|
||||
expect(s.gate(t), MediaGate.forbidden);
|
||||
expect(dropped, isTrue);
|
||||
expect(gotReason, 'idle');
|
||||
});
|
||||
|
||||
test('oversize avatar rejected', () {
|
||||
final s = Session(id: 'default', secret: 'shh');
|
||||
final big = 'x' * (RelayConstants.maxAvatarBytes + 1);
|
||||
@@ -122,18 +106,6 @@ void main() {
|
||||
expect(() => WelcomeMessage.fromJson({'t': 'hello'}), throwsA(isA<ProtocolException>()));
|
||||
});
|
||||
|
||||
test('kicked + kick request round-trip', () {
|
||||
const k = KickedMessage(reason: 'idle');
|
||||
final back = KickedMessage.fromJson(Map<String, Object?>.from(k.toJson()));
|
||||
expect(back.reason, 'idle');
|
||||
const r = KickRequest(target: 'abc', reason: 'afk?');
|
||||
final backR = KickRequest.fromJson(Map<String, Object?>.from(r.toJson()));
|
||||
expect(backR.target, 'abc');
|
||||
expect(backR.reason, 'afk?');
|
||||
expect(() => KickedMessage.fromJson({'t': 'err'}), throwsA(isA<ProtocolException>()));
|
||||
expect(() => KickRequest.fromJson({'t': 'err'}), throwsA(isA<ProtocolException>()));
|
||||
});
|
||||
|
||||
test('lastSeen touch + stale helper', () {
|
||||
final s = Session(id: 'default', secret: 'shh');
|
||||
final t = s.issueToken('alice', '');
|
||||
|
||||
@@ -127,17 +127,10 @@ void main() {
|
||||
await second.ws.close();
|
||||
});
|
||||
|
||||
test('unknown or kicked resume token joins as a new member', () async {
|
||||
test('unknown resume token joins as a new member', () async {
|
||||
final (a, wa) = await _Client.join(server.boundPort, 'a', resume: 'nope');
|
||||
expect(wa.token, isNot('nope'));
|
||||
final (b, wb) = await _Client.join(server.boundPort, 'b');
|
||||
await server.hub.kickById(wb.memberId, 'bye');
|
||||
final (c, wc) = await _Client.join(server.boundPort, 'b', resume: wb.token);
|
||||
expect(wc.token, isNot(wb.token));
|
||||
expect(wc.memberId, isNot(wb.memberId));
|
||||
await a.ws.close();
|
||||
await b.ws.close();
|
||||
await c.ws.close();
|
||||
});
|
||||
|
||||
test('ping gets a pong with the client stamp and the server clock', () async {
|
||||
|
||||
Reference in new issue
Block a user