Initial commit: watch-together phases 0-4 + presence hardening, internet-tested

This commit is contained in:
foxyz committed 2026-09-12 00:13:43 +03:00
commit 427d851a5b
101 files changed
+11326

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
export 'src/media_endpoint.dart';
export 'src/room_hub.dart';
export 'src/room_server.dart';
export 'src/session.dart';
+213
View File
@@ -0,0 +1,213 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:relic/relic.dart';
import 'package:sync_relay_core/core.dart';
import 'session.dart';
class MediaLog {
final String method;
final String path;
final String? rangeHeader;
final int status;
final int bytesWritten;
final int durationMs;
const MediaLog({
required this.method,
required this.path,
required this.rangeHeader,
required this.status,
required this.bytesWritten,
required this.durationMs,
});
@override
String toString() =>
'$method $path range=${rangeHeader ?? '-'} -> $status bytes=$bytesWritten ${durationMs}ms';
}
class MediaResolution {
final int status;
final Map<String, String> headers;
final int start;
final int? end;
const MediaResolution({
required this.status,
required this.headers,
required this.start,
required this.end,
});
int contentLength(int size) => (end ?? (size - 1)) - start + 1;
}
MediaResolution resolveMediaRequest({
required String? rangeHeader,
required int size,
required String filename,
MediaGate gate = MediaGate.ok,
}) {
Map<String, String> base(String contentType) => {
'accept-ranges': 'bytes',
'content-type': contentType,
};
final contentType = contentTypeForFile(filename);
switch (gate) {
case MediaGate.gone:
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,
headers: {...base(contentType), 'retry-after': '2'},
start: 0,
end: 0);
case MediaGate.ok:
break;
}
if (rangeHeader == null || rangeHeader.trim().isEmpty) {
return MediaResolution(
status: 200,
headers: {...base(contentType), 'content-length': '$size'},
start: 0,
end: null,
);
}
final parsed = RangeRequest.parse(rangeHeader, size);
if (parsed == null) {
return MediaResolution(
status: 416,
headers: {...base(contentType), 'content-range': 'bytes */$size'},
start: 0,
end: 0,
);
}
final end = parsed.end ?? size - 1;
return MediaResolution(
status: 206,
headers: {
...base(contentType),
'content-range': 'bytes ${parsed.start}-$end/$size',
'content-length': '${end - parsed.start + 1}',
},
start: parsed.start,
end: parsed.end,
);
}
void mountMediaRoute(RelicApp app, File file,
{String route = '/m/file', void Function(MediaLog log)? onLog}) {
app.get(route, (final req) async {
final stopwatch = Stopwatch()..start();
final size = await file.length();
final name = file.path.split(Platform.pathSeparator).last;
final typed = req.headers.range;
String? rangeHeader;
if (typed != null && typed.ranges.isNotEmpty) {
final r = typed.ranges.first;
rangeHeader = '${typed.unit}=${r.start?.toString() ?? ''}-${r.end?.toString() ?? ''}';
}
final res = resolveMediaRequest(rangeHeader: rangeHeader, size: size, filename: name);
void log(int bytes) => onLog?.call(MediaLog(
method: req.method.name,
path: route,
rangeHeader: rangeHeader,
status: res.status,
bytesWritten: bytes,
durationMs: stopwatch.elapsedMilliseconds,
));
if (res.status != 200 && res.status != 206) {
log(0);
return Response(
res.status,
body: Body.fromString('status ${res.status}'),
headers: Headers.build((h) {
res.headers.forEach((k, v) => h[k] = [v]);
}),
);
}
final end = res.end ?? size - 1;
final byteCount = end - res.start + 1;
final stream = file.openRead(res.start, end + 1).map((c) => Uint8List.fromList(c));
log(byteCount);
return Response(
res.status,
body: Body.fromDataStream(stream, contentLength: byteCount),
headers: Headers.build((h) {
res.headers.forEach((k, v) => h[k] = [v]);
}),
);
});
}
Response _gatedResponse(MediaResolution res) => Response(
res.status,
body: Body.fromString('status ${res.status}'),
headers: Headers.build((h) {
res.headers.forEach((k, v) => h[k] = [v]);
}),
);
void mountSessionMediaRoute(
RelicApp app,
SessionMap sessions, {
String pattern = '/m/:sid/:token/:name',
void Function(String token, MediaLog log)? onLog,
}) {
app.get(pattern, (final req) async {
final stopwatch = Stopwatch()..start();
final sid = req.pathParameters.raw[#sid] ?? '';
final token = req.pathParameters.raw[#token] ?? '';
final session = sessions.get(sid);
if (session == null) {
return _gatedResponse(resolveMediaRequest(
rangeHeader: null, size: 1, filename: 'x', gate: MediaGate.gone));
}
final gate = session.gate(token);
if (gate != MediaGate.ok) {
return _gatedResponse(
resolveMediaRequest(rangeHeader: null, size: 1, filename: 'x', gate: gate));
}
final media = session.media;
final file = media?.file;
if (media == null || file == null) {
return _gatedResponse(resolveMediaRequest(
rangeHeader: null, size: 1, filename: 'x', gate: MediaGate.offline));
}
final size = media.size;
final typed = req.headers.range;
String? rangeHeader;
if (typed != null && typed.ranges.isNotEmpty) {
final r = typed.ranges.first;
rangeHeader = '${typed.unit}=${r.start?.toString() ?? ''}-${r.end?.toString() ?? ''}';
}
final res = resolveMediaRequest(rangeHeader: rangeHeader, size: size, filename: media.name);
if (res.status != 200 && res.status != 206) {
return _gatedResponse(res);
}
final end = res.end ?? size - 1;
final byteCount = end - res.start + 1;
session.addBytes(token, byteCount);
final shortToken = token.length > 4 ? '…${token.substring(token.length - 4)}' : token;
onLog?.call(token, MediaLog(
method: req.method.name,
path: '/m/$sid/$shortToken/${media.name}',
rangeHeader: rangeHeader,
status: res.status,
bytesWritten: byteCount,
durationMs: stopwatch.elapsedMilliseconds,
));
final stream = file.openRead(res.start, end + 1).map((c) => Uint8List.fromList(c));
return Response(
res.status,
body: Body.fromDataStream(stream, contentLength: byteCount),
headers: Headers.build((h) {
res.headers.forEach((k, v) => h[k] = [v]);
}),
);
});
}
+297
View File
@@ -0,0 +1,297 @@
import 'dart:async';
import 'dart:convert';
import 'package:relic/relic.dart';
import 'package:sync_relay_core/core.dart';
import 'package:web_socket/web_socket.dart';
import 'session.dart';
final _hubLog = createLogger('room_hub');
class RoomHub {
final SessionMap sessions;
final String expectedSecret;
final RoomEngine engine;
final Map<String, RelicWebSocket> sockets = {};
RoomHub(this.sessions, this.expectedSecret, {RoomEngine? engine})
: engine = engine ?? RoomEngine();
Session get session => sessions.getOrCreate('default', expectedSecret);
void broadcastRoom() {
final raw = jsonEncode(engine.broadcast().toJson());
for (final ws in sockets.values.toList()) {
ws.trySendText(raw);
}
}
void broadcastFeed() {
for (final feed in engine.feed.drain()) {
final raw = jsonEncode(feed.toJson());
for (final ws in sockets.values.toList()) {
ws.trySendText(raw);
}
}
}
void dropSilentSockets() {
for (final token in engine.sweepDropped()) {
final ws = sockets[token];
if (ws != null) unawaited(ws.tryClose());
forgetSocket(token);
}
}
void sweepClosedSockets() {
for (final entry in sockets.entries.toList()) {
var closed = false;
try {
closed = entry.value.isClosed;
} catch (_) {
closed = true;
}
if (closed) forgetSocket(entry.key);
}
}
Future<void> closeAllSockets() async {
final all = sockets.values.toList();
sockets.clear();
for (final ws in all) {
try {
await ws.tryClose();
} catch (_) {}
}
}
void broadcastChat(String from, String text) {
final clean = text.length > RelayConstants.maxChatLength
? text.substring(0, RelayConstants.maxChatLength)
: text;
final raw = jsonEncode(
ChatMessage(text: clean).toJson()..addAll({'from': from}));
for (final ws in sockets.values.toList()) {
ws.trySendText(raw);
}
engine.feed.add(
FeedMessage(actor: from, action: 'chat', at: engine.now));
broadcastFeed();
}
void changed() {
broadcastRoom();
broadcastFeed();
}
void forgetSocket(String token) {
engine.applyLeave(token);
final viewer = session.tokens[token];
if (viewer != null && !viewer.kicked) 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');
await session.kick(member.token, reason: reason);
}
}
void wsErr(RelicWebSocket ws, String message) {
ws.trySendText(jsonEncode({'t': 'err', 'message': message}));
}
void mountSyncRoute(
RelicApp app,
RoomHub hub,
String Function(String sid, String token, SessionMedia? media) urlBuilder,
) {
app.get('/sync', (final req) {
return WebSocketUpgrade((final ws) async {
ws.pingInterval = Duration(seconds: RelayConstants.pingIntervalSecs);
final firstText = Completer<String>();
String? token;
var welcomed = false;
late StreamSubscription<WebSocketEvent> sub;
Future<void> close() async {
try {
await sub.cancel();
} catch (_) {}
await ws.tryClose();
}
void handleIntent(String text) {
final session = hub.session;
final viewer = token == null ? null : session.tokens[token];
viewer?.touch();
Map<String, Object?> decoded;
try {
final parsed = jsonDecode(text);
if (parsed is! Map) return;
decoded = Map<String, Object?>.from(parsed);
} catch (_) {
return;
}
final type = decoded['t'];
try {
switch (type) {
case 'ready':
if (token == null) return;
final intent = ReadyIntent.fromJson(decoded);
hub.engine.applyReady(token, intent.ready, source: intent.source);
hub.changed();
case 'force':
if (token == null) return;
final intent = ForceIntent.fromJson(decoded);
hub.engine.applyForce(token, intent.target, intent.ready);
hub.changed();
case 'seek':
if (token == null) return;
final intent = SeekIntent.fromJson(decoded);
hub.engine.applySeek(token, intent.position);
hub.changed();
case 'swap':
if (token == null) return;
final intent = SwapIntent.fromJson(decoded);
hub.engine.applySwap(token, intent.media);
final current = session.media;
if (current?.file != null &&
current!.name == intent.media.name &&
current.size == intent.media.size) {
session.media = SessionMedia(
name: current.name,
size: current.size,
duration: intent.media.duration,
url: intent.media.url,
file: current.file,
mediaId: hub.engine.mediaId);
} else {
session.media = SessionMedia(
name: intent.media.name,
size: intent.media.size,
duration: intent.media.duration,
url: intent.media.url,
mediaId: hub.engine.mediaId);
}
hub.changed();
case 'report':
if (token == null) return;
final report = ReportMessage.fromJson(decoded);
hub.engine.applyReport(
token, report.position, report.buffered, report.eventId);
hub.broadcastRoom();
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;
}
final by = viewer?.name ?? '?';
unawaited(hub.session.kick(member.token,
reason: kickReq.reason.isEmpty
? 'kicked by $by'
: kickReq.reason));
}
} catch (_) {
return;
}
}
sub = ws.events.listen(
(event) {
if (event is! TextDataReceived) return;
if (!firstText.isCompleted) {
firstText.complete(event.text);
} else if (welcomed) {
handleIntent(event.text);
}
},
onError: (_) {
if (token != null) hub.forgetSocket(token);
},
onDone: () {
if (!firstText.isCompleted) {
firstText.completeError(StateError('closed before hello'));
} else if (token != null) {
hub.forgetSocket(token);
}
},
);
String raw;
try {
raw = await firstText.future.timeout(const Duration(seconds: 10));
} catch (_) {
await close();
return;
}
HelloMessage hello;
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) throw const ProtocolException('hello must be an object');
hello = HelloMessage.fromJson(Map<String, Object?>.from(decoded));
} catch (e) {
_hubLog.warning('reject: bad hello: $e');
wsErr(ws, 'bad hello: $e');
await close();
return;
}
if (hello.proto != RelayConstants.protoVersion) {
_hubLog.warning(
'reject ${hello.user.name}: proto mismatch: got ${hello.proto}, want ${RelayConstants.protoVersion}');
wsErr(ws, 'proto mismatch: got ${hello.proto}, want ${RelayConstants.protoVersion}');
await close();
return;
}
final session = hub.session;
if (hello.secret != session.secret) {
_hubLog.warning('reject ${hello.user.name}: wrong secret');
wsErr(ws, 'wrong secret');
await close();
return;
}
try {
token = session.issueToken(hello.user.name, hello.user.avatar);
} catch (e) {
_hubLog.warning('reject ${hello.user.name}: $e');
wsErr(ws, 'rejected: $e');
await close();
return;
}
final memberId = hub.engine.applyJoin(token, hello.user.name, hello.user.avatar);
final activeToken = token;
hub.sockets[activeToken] = ws;
session.tokens[activeToken]!.onKick = (reason) async {
ws.trySendText(jsonEncode(KickedMessage(reason: reason).toJson()));
await Future.delayed(const Duration(milliseconds: 200));
await ws.tryClose();
hub.forgetSocket(activeToken);
};
final media = session.media;
final welcome = WelcomeMessage(
token: activeToken,
memberId: memberId,
mediaUrl: media?.file != null ? urlBuilder('default', activeToken, media) : media?.url,
state: hub.engine.broadcast().toJson(),
);
try {
ws.sendText(jsonEncode(welcome.toJson()));
} catch (_) {
await close();
return;
}
welcomed = true;
_hubLog.info('join ${hello.user.name} ($memberId)');
hub.changed();
try {
await sub.asFuture();
} catch (_) {}
});
});
}
+113
View File
@@ -0,0 +1,113 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:logging/logging.dart' as pkg_logging;
import 'package:relic/relic.dart';
import 'package:sync_relay_core/core.dart';
import 'media_endpoint.dart';
import 'room_hub.dart';
import 'session.dart';
class RoomServer {
final AppConfig config;
final pkg_logging.Logger _log = createLogger('room_server');
final RelicApp _app = RelicApp();
final SessionMap sessions = SessionMap();
late final RoomHub hub;
RelicServer? _server;
DateTime? _startedAt;
Timer? _heartbeat;
bool _routesMounted = false;
RoomServer(this.config) {
hub = RoomHub(sessions, config.secret);
}
bool get isRunning => _server != null;
int get boundPort => _server?.port ?? config.port;
Session get session => sessions.getOrCreate('default', config.secret);
Future<bool> setLocalFile(String path) async {
final file = File(path);
if (!await file.exists()) return false;
final name = file.path.split(Platform.pathSeparator).last;
final size = await file.length();
session.media = SessionMedia(name: name, size: size, duration: 0, file: file);
hub.engine.media = HelloMedia(name: name, size: size, duration: 0);
return true;
}
void _mountRoutes() {
if (_routesMounted) return;
_routesMounted = true;
_app.get('/health', (final req) {
final s = sessions.get('default');
final viewers = s?.tokens.length ?? 0;
final bytes = s?.totalBytesServed ?? 0;
final uptime = _startedAt == null ? 0 : DateTime.now().difference(_startedAt!).inSeconds;
return Response.ok(
body: Body.fromString(jsonEncode({
'ok': true,
'room': config.name,
'proto': RelayConstants.protoVersion,
'viewers': viewers,
'bytesServed': bytes,
'uptimeSecs': uptime,
'media': s?.media?.name,
'transport': hub.engine.transport.name,
'eventId': hub.engine.eventId,
})),
);
});
mountSessionMediaRoute(_app, sessions);
mountSyncRoute(_app, hub, (sid, token, media) {
final name = Uri.encodeComponent(media?.name ?? 'file');
return 'http://${config.advertiseHost}:$boundPort/m/$sid/$token/$name';
});
_app.fallback = (final req) => Response.notFound(
body: Body.fromString('not found'),
);
}
Future<void> start() async {
if (_server != null) return;
final s = session;
if (config.mediaPath.isNotEmpty) {
final file = File(config.mediaPath);
if (await file.exists()) {
final name = file.path.split(Platform.pathSeparator).last;
final size = await file.length();
s.media = SessionMedia(name: name, size: size, duration: 0, file: file);
hub.engine.media = HelloMedia(name: name, size: size, duration: 0);
} else {
_log.warning('media file not found: ${config.mediaPath}');
}
}
_mountRoutes();
_server = await _app.serve(
address: InternetAddress.anyIPv4,
port: config.port,
);
_startedAt = DateTime.now();
_heartbeat = Timer.periodic(const Duration(seconds: 1), (_) {
hub.engine.sweepStale();
hub.sweepClosedSockets();
hub.dropSilentSockets();
hub.broadcastRoom();
hub.broadcastFeed();
});
_log.info('room "${config.name}" listening on $boundPort');
}
Future<void> stop() async {
if (_server == null) return;
_heartbeat?.cancel();
_heartbeat = null;
await hub.closeAllSockets();
await _app.close();
_server = null;
_log.info('room "${config.name}" stopped');
}
}
+95
View File
@@ -0,0 +1,95 @@
import 'dart:io';
import 'dart:math';
import 'package:sync_relay_core/core.dart';
enum MediaGate { ok, gone, unauthorized, forbidden, offline }
class ViewerToken {
final String token;
final String name;
final String avatar;
bool kicked = false;
int bytesServed = 0;
DateTime lastSeen;
Future<void> Function(String reason)? onKick;
ViewerToken({required this.token, required this.name, this.avatar = ''})
: lastSeen = DateTime.now().toUtc();
bool isStale(Duration timeout, [DateTime? now]) =>
(now ?? DateTime.now().toUtc()).difference(lastSeen) > timeout;
void touch([DateTime? now]) => lastSeen = now ?? DateTime.now().toUtc();
}
class SessionMedia {
final String name;
final int size;
final double duration;
final String? url;
final File? file;
final int mediaId;
const SessionMedia({
required this.name,
required this.size,
required this.duration,
this.url,
this.file,
this.mediaId = 1,
});
}
class Session {
final String id;
final String secret;
final Map<String, ViewerToken> tokens = {};
SessionMedia? media;
final _random = Random.secure();
Session({required this.id, required this.secret});
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);
return token;
}
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;
}
int get totalBytesServed => tokens.values.fold(0, (a, v) => a + v.bytesServed);
}
class SessionMap {
final Map<String, Session> _sessions = {};
Session getOrCreate(String id, String secret) =>
_sessions.putIfAbsent(id, () => Session(id: id, secret: secret));
Session? get(String id) => _sessions[id];
void drop(String id) => _sessions.remove(id);
}