From 2fc03fc7243139a2872c386c6f021f8f4dbb4d27 Mon Sep 17 00:00:00 2001 From: CodeDoctorDE Date: Mon, 10 Aug 2026 21:13:51 +0200 Subject: [PATCH 1/8] Add role system --- api/lib/models.dart | 1 + api/lib/src/event/client.dart | 37 + api/lib/src/event/event.mapper.dart | 799 +++++++++++++++++++ api/lib/src/event/process/client.dart | 5 + api/lib/src/event/process/server.dart | 12 + api/lib/src/event/server.dart | 12 + api/lib/src/event/state.dart | 7 + api/lib/src/event/state.mapper.dart | 19 + api/lib/src/models/authorization.dart | 55 ++ api/lib/src/models/authorization.mapper.dart | 169 ++++ api/lib/src/models/config.dart | 5 + api/lib/src/models/config.mapper.dart | 32 + api/lib/src/models/server.dart | 29 +- api/lib/src/models/server.mapper.dart | 153 +++- api/lib/src/models/translation.dart | 45 +- api/lib/src/models/translation.mapper.dart | 362 +++++++++ api/lib/src/services/user.dart | 70 ++ api/lib/src/services/user.mapper.dart | 50 ++ api/test/game_roles_test.dart | 32 + api/test/translation_test.dart | 30 + app/lib/helpers/asset.dart | 17 + app/lib/l10n/app_en.arb | 12 + app/lib/pages/game/error.dart | 6 +- app/lib/pages/game/multiplayer/dialog.dart | 4 + app/lib/pages/game/multiplayer/view.dart | 336 +++++++- app/pack/translations/en.json | 31 + plugin/lib/src/plugin.dart | 1 + plugin/lib/src/rust/api/plugin.dart | 1 + plugin/rust/src/api/plugin.rs | 1 + plugin/rust/src/frb_generated.rs | 15 +- server/README.md | 36 + server/lib/setonix_server.dart | 1 + server/lib/src/authorization.dart | 50 ++ server/lib/src/bloc.dart | 94 ++- server/lib/src/config.dart | 3 + server/lib/src/programs/ban.dart | 58 ++ server/lib/src/programs/players.dart | 2 +- server/lib/src/programs/role.dart | 33 + server/lib/src/server.dart | 65 +- server/lib/src/services/user/file.dart | 42 + server/lib/src/services/user/migrations.dart | 5 + server/lib/src/services/user/remote.dart | 12 + server/test/authorization_test.dart | 131 +++ server/test/user_role_test.dart | 62 ++ 44 files changed, 2895 insertions(+), 47 deletions(-) create mode 100644 api/lib/src/models/authorization.dart create mode 100644 api/lib/src/models/authorization.mapper.dart create mode 100644 api/test/game_roles_test.dart create mode 100644 api/test/translation_test.dart create mode 100644 server/lib/src/authorization.dart create mode 100644 server/lib/src/programs/ban.dart create mode 100644 server/lib/src/programs/role.dart create mode 100644 server/test/authorization_test.dart create mode 100644 server/test/user_role_test.dart diff --git a/api/lib/models.dart b/api/lib/models.dart index bf89257e..44a8c65a 100644 --- a/api/lib/models.dart +++ b/api/lib/models.dart @@ -1,6 +1,7 @@ export 'src/helpers/equality.dart'; export 'src/models/background.dart'; +export 'src/models/authorization.dart'; export 'src/models/cell.dart'; export 'src/models/chat.dart'; export 'src/models/config.dart'; diff --git a/api/lib/src/event/client.dart b/api/lib/src/event/client.dart index c8fbff14..214ce205 100644 --- a/api/lib/src/event/client.dart +++ b/api/lib/src/event/client.dart @@ -199,3 +199,40 @@ final class ToolbarActionRequest extends ClientWorldEvent ToolbarActionRequest(this.actionId); } + +@MappableClass() +final class KickPlayerRequest extends ClientWorldEvent + with KickPlayerRequestMappable { + final Channel player; + final String? reason; + + KickPlayerRequest(this.player, {this.reason}); +} + +@MappableClass() +final class BanPlayerRequest extends ClientWorldEvent + with BanPlayerRequestMappable { + final Channel player; + final String? reason; + final DateTime? expiresAt; + + BanPlayerRequest(this.player, {this.reason, this.expiresAt}); +} + +@MappableClass() +final class ServerRoleChangeRequest extends ClientWorldEvent + with ServerRoleChangeRequestMappable { + final Channel player; + final String role; + + ServerRoleChangeRequest(this.player, this.role); +} + +@MappableClass() +final class GameRolesChangeRequest extends ClientWorldEvent + with GameRolesChangeRequestMappable { + final Channel player; + final Set roles; + + GameRolesChangeRequest(this.player, [this.roles = const {}]); +} diff --git a/api/lib/src/event/event.mapper.dart b/api/lib/src/event/event.mapper.dart index 9f2b5bc1..54236d98 100644 --- a/api/lib/src/event/event.mapper.dart +++ b/api/lib/src/event/event.mapper.dart @@ -148,6 +148,7 @@ class ServerWorldEventMapper extends SubClassMapperBase { MapperContainer.globals.use(_instance = ServerWorldEventMapper._()); PlayableWorldEventMapper.ensureInitialized().addSubMapper(_instance!); WorldInitializedMapper.ensureInitialized(); + GameRolesChangedMapper.ensureInitialized(); TeamJoinedMapper.ensureInitialized(); TeamLeftMapper.ensureInitialized(); ObjectsChangedMapper.ensureInitialized(); @@ -253,6 +254,10 @@ class WorldInitializedMapper extends SubClassMapperBase { v.teamMembers; static const Field>> _f$teamMembers = Field('teamMembers', _$teamMembers, opt: true); + static Map>? _$gameRoleMembers(WorldInitialized v) => + v.gameRoleMembers; + static const Field>> + _f$gameRoleMembers = Field('gameRoleMembers', _$gameRoleMembers, opt: true); static int? _$id(WorldInitialized v) => v.id; static const Field _f$id = Field( 'id', @@ -276,6 +281,7 @@ class WorldInitializedMapper extends SubClassMapperBase { #table: _f$table, #info: _f$info, #teamMembers: _f$teamMembers, + #gameRoleMembers: _f$gameRoleMembers, #id: _f$id, #packsSignature: _f$packsSignature, #clearUserInterface: _f$clearUserInterface, @@ -294,6 +300,7 @@ class WorldInitializedMapper extends SubClassMapperBase { table: data.dec(_f$table), info: data.dec(_f$info), teamMembers: data.dec(_f$teamMembers), + gameRoleMembers: data.dec(_f$gameRoleMembers), id: data.dec(_f$id), packsSignature: data.dec(_f$packsSignature), clearUserInterface: data.dec(_f$clearUserInterface), @@ -369,6 +376,8 @@ abstract class WorldInitializedCopyWith<$R, $In extends WorldInitialized, $Out> GameInfoCopyWith<$R, GameInfo, GameInfo>? get info; MapCopyWith<$R, String, Set, ObjectCopyWith<$R, Set, Set>>? get teamMembers; + MapCopyWith<$R, String, Set, ObjectCopyWith<$R, Set, Set>>? + get gameRoleMembers; ListCopyWith< $R, SignatureMetadata, @@ -380,6 +389,7 @@ abstract class WorldInitializedCopyWith<$R, $In extends WorldInitialized, $Out> GameTable? table, GameInfo? info, Map>? teamMembers, + Map>? gameRoleMembers, int? id, List? packsSignature, bool? clearUserInterface, @@ -414,6 +424,15 @@ class _WorldInitializedCopyWithImpl<$R, $Out> ) : null; @override + MapCopyWith<$R, String, Set, ObjectCopyWith<$R, Set, Set>>? + get gameRoleMembers => $value.gameRoleMembers != null + ? MapCopyWith( + $value.gameRoleMembers!, + (v, t) => ObjectCopyWith(v, $identity, t), + (v) => call(gameRoleMembers: v), + ) + : null; + @override ListCopyWith< $R, SignatureMetadata, @@ -431,6 +450,7 @@ class _WorldInitializedCopyWithImpl<$R, $Out> Object? table = $none, Object? info = $none, Object? teamMembers = $none, + Object? gameRoleMembers = $none, Object? id = $none, Object? packsSignature = $none, bool? clearUserInterface, @@ -439,6 +459,7 @@ class _WorldInitializedCopyWithImpl<$R, $Out> if (table != $none) #table: table, if (info != $none) #info: info, if (teamMembers != $none) #teamMembers: teamMembers, + if (gameRoleMembers != $none) #gameRoleMembers: gameRoleMembers, if (id != $none) #id: id, if (packsSignature != $none) #packsSignature: packsSignature, if (clearUserInterface != null) #clearUserInterface: clearUserInterface, @@ -449,6 +470,7 @@ class _WorldInitializedCopyWithImpl<$R, $Out> table: data.get(#table, or: $value.table), info: data.get(#info, or: $value.info), teamMembers: data.get(#teamMembers, or: $value.teamMembers), + gameRoleMembers: data.get(#gameRoleMembers, or: $value.gameRoleMembers), id: data.get(#id, or: $value.id), packsSignature: data.get(#packsSignature, or: $value.packsSignature), clearUserInterface: data.get( @@ -463,6 +485,150 @@ class _WorldInitializedCopyWithImpl<$R, $Out> ) => _WorldInitializedCopyWithImpl<$R2, $Out2>($value, $cast, t); } +/// @nodoc +class GameRolesChangedMapper extends SubClassMapperBase { + GameRolesChangedMapper._(); + + static GameRolesChangedMapper? _instance; + static GameRolesChangedMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = GameRolesChangedMapper._()); + ServerWorldEventMapper.ensureInitialized().addSubMapper(_instance!); + } + return _instance!; + } + + @override + final String id = 'GameRolesChanged'; + + static int _$user(GameRolesChanged v) => v.user; + static const Field _f$user = Field('user', _$user); + static Set _$roles(GameRolesChanged v) => v.roles; + static const Field> _f$roles = Field( + 'roles', + _$roles, + opt: true, + def: const {}, + ); + + @override + final MappableFields fields = const { + #user: _f$user, + #roles: _f$roles, + }; + + @override + final String discriminatorKey = 'type'; + @override + final dynamic discriminatorValue = 'GameRolesChanged'; + @override + late final ClassMapperBase superMapper = + ServerWorldEventMapper.ensureInitialized(); + + static GameRolesChanged _instantiate(DecodingData data) { + return GameRolesChanged(data.dec(_f$user), data.dec(_f$roles)); + } + + @override + final Function instantiate = _instantiate; + + static GameRolesChanged fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static GameRolesChanged fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +/// @nodoc +mixin GameRolesChangedMappable { + String toJson() { + return GameRolesChangedMapper.ensureInitialized() + .encodeJson(this as GameRolesChanged); + } + + Map toMap() { + return GameRolesChangedMapper.ensureInitialized() + .encodeMap(this as GameRolesChanged); + } + + GameRolesChangedCopyWith + get copyWith => + _GameRolesChangedCopyWithImpl( + this as GameRolesChanged, + $identity, + $identity, + ); + @override + String toString() { + return GameRolesChangedMapper.ensureInitialized().stringifyValue( + this as GameRolesChanged, + ); + } + + @override + bool operator ==(Object other) { + return GameRolesChangedMapper.ensureInitialized().equalsValue( + this as GameRolesChanged, + other, + ); + } + + @override + int get hashCode { + return GameRolesChangedMapper.ensureInitialized().hashValue( + this as GameRolesChanged, + ); + } +} + +/// @nodoc +extension GameRolesChangedValueCopy<$R, $Out> + on ObjectCopyWith<$R, GameRolesChanged, $Out> { + GameRolesChangedCopyWith<$R, GameRolesChanged, $Out> + get $asGameRolesChanged => + $base.as((v, t, t2) => _GameRolesChangedCopyWithImpl<$R, $Out>(v, t, t2)); +} + +/// @nodoc +abstract class GameRolesChangedCopyWith<$R, $In extends GameRolesChanged, $Out> + implements ServerWorldEventCopyWith<$R, $In, $Out> { + @override + $R call({int? user, Set? roles}); + GameRolesChangedCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ); +} + +/// @nodoc +class _GameRolesChangedCopyWithImpl<$R, $Out> + extends ClassCopyWithBase<$R, GameRolesChanged, $Out> + implements GameRolesChangedCopyWith<$R, GameRolesChanged, $Out> { + _GameRolesChangedCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = + GameRolesChangedMapper.ensureInitialized(); + @override + $R call({int? user, Set? roles}) => $apply( + FieldCopyWithData({ + if (user != null) #user: user, + if (roles != null) #roles: roles, + }), + ); + @override + GameRolesChanged $make(CopyWithData data) => GameRolesChanged( + data.get(#user, or: $value.user), + data.get(#roles, or: $value.roles), + ); + + @override + GameRolesChangedCopyWith<$R2, GameRolesChanged, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ) => _GameRolesChangedCopyWithImpl<$R2, $Out2>($value, $cast, t); +} + /// @nodoc class TeamJoinedMapper extends SubClassMapperBase { TeamJoinedMapper._(); @@ -2449,6 +2615,10 @@ class ClientWorldEventMapper extends SubClassMapperBase { ModeChangeRequestMapper.ensureInitialized(); AuthenticateRequestMapper.ensureInitialized(); ToolbarActionRequestMapper.ensureInitialized(); + KickPlayerRequestMapper.ensureInitialized(); + BanPlayerRequestMapper.ensureInitialized(); + ServerRoleChangeRequestMapper.ensureInitialized(); + GameRolesChangeRequestMapper.ensureInitialized(); HybridWorldEventMapper.ensureInitialized(); } return _instance!; @@ -4613,6 +4783,635 @@ class _ToolbarActionRequestCopyWithImpl<$R, $Out> _ToolbarActionRequestCopyWithImpl<$R2, $Out2>($value, $cast, t); } +/// @nodoc +class KickPlayerRequestMapper extends SubClassMapperBase { + KickPlayerRequestMapper._(); + + static KickPlayerRequestMapper? _instance; + static KickPlayerRequestMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = KickPlayerRequestMapper._()); + ClientWorldEventMapper.ensureInitialized().addSubMapper(_instance!); + } + return _instance!; + } + + @override + final String id = 'KickPlayerRequest'; + + static int _$player(KickPlayerRequest v) => v.player; + static const Field _f$player = Field( + 'player', + _$player, + ); + static String? _$reason(KickPlayerRequest v) => v.reason; + static const Field _f$reason = Field( + 'reason', + _$reason, + opt: true, + ); + + @override + final MappableFields fields = const { + #player: _f$player, + #reason: _f$reason, + }; + + @override + final String discriminatorKey = 'type'; + @override + final dynamic discriminatorValue = 'KickPlayerRequest'; + @override + late final ClassMapperBase superMapper = + ClientWorldEventMapper.ensureInitialized(); + + static KickPlayerRequest _instantiate(DecodingData data) { + return KickPlayerRequest(data.dec(_f$player), reason: data.dec(_f$reason)); + } + + @override + final Function instantiate = _instantiate; + + static KickPlayerRequest fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static KickPlayerRequest fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +/// @nodoc +mixin KickPlayerRequestMappable { + String toJson() { + return KickPlayerRequestMapper.ensureInitialized() + .encodeJson(this as KickPlayerRequest); + } + + Map toMap() { + return KickPlayerRequestMapper.ensureInitialized() + .encodeMap(this as KickPlayerRequest); + } + + KickPlayerRequestCopyWith< + KickPlayerRequest, + KickPlayerRequest, + KickPlayerRequest + > + get copyWith => + _KickPlayerRequestCopyWithImpl( + this as KickPlayerRequest, + $identity, + $identity, + ); + @override + String toString() { + return KickPlayerRequestMapper.ensureInitialized().stringifyValue( + this as KickPlayerRequest, + ); + } + + @override + bool operator ==(Object other) { + return KickPlayerRequestMapper.ensureInitialized().equalsValue( + this as KickPlayerRequest, + other, + ); + } + + @override + int get hashCode { + return KickPlayerRequestMapper.ensureInitialized().hashValue( + this as KickPlayerRequest, + ); + } +} + +/// @nodoc +extension KickPlayerRequestValueCopy<$R, $Out> + on ObjectCopyWith<$R, KickPlayerRequest, $Out> { + KickPlayerRequestCopyWith<$R, KickPlayerRequest, $Out> + get $asKickPlayerRequest => $base.as( + (v, t, t2) => _KickPlayerRequestCopyWithImpl<$R, $Out>(v, t, t2), + ); +} + +/// @nodoc +abstract class KickPlayerRequestCopyWith< + $R, + $In extends KickPlayerRequest, + $Out +> + implements ClientWorldEventCopyWith<$R, $In, $Out> { + @override + $R call({int? player, String? reason}); + KickPlayerRequestCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ); +} + +/// @nodoc +class _KickPlayerRequestCopyWithImpl<$R, $Out> + extends ClassCopyWithBase<$R, KickPlayerRequest, $Out> + implements KickPlayerRequestCopyWith<$R, KickPlayerRequest, $Out> { + _KickPlayerRequestCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = + KickPlayerRequestMapper.ensureInitialized(); + @override + $R call({int? player, Object? reason = $none}) => $apply( + FieldCopyWithData({ + if (player != null) #player: player, + if (reason != $none) #reason: reason, + }), + ); + @override + KickPlayerRequest $make(CopyWithData data) => KickPlayerRequest( + data.get(#player, or: $value.player), + reason: data.get(#reason, or: $value.reason), + ); + + @override + KickPlayerRequestCopyWith<$R2, KickPlayerRequest, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ) => _KickPlayerRequestCopyWithImpl<$R2, $Out2>($value, $cast, t); +} + +/// @nodoc +class BanPlayerRequestMapper extends SubClassMapperBase { + BanPlayerRequestMapper._(); + + static BanPlayerRequestMapper? _instance; + static BanPlayerRequestMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = BanPlayerRequestMapper._()); + ClientWorldEventMapper.ensureInitialized().addSubMapper(_instance!); + } + return _instance!; + } + + @override + final String id = 'BanPlayerRequest'; + + static int _$player(BanPlayerRequest v) => v.player; + static const Field _f$player = Field( + 'player', + _$player, + ); + static String? _$reason(BanPlayerRequest v) => v.reason; + static const Field _f$reason = Field( + 'reason', + _$reason, + opt: true, + ); + static DateTime? _$expiresAt(BanPlayerRequest v) => v.expiresAt; + static const Field _f$expiresAt = Field( + 'expiresAt', + _$expiresAt, + opt: true, + ); + + @override + final MappableFields fields = const { + #player: _f$player, + #reason: _f$reason, + #expiresAt: _f$expiresAt, + }; + + @override + final String discriminatorKey = 'type'; + @override + final dynamic discriminatorValue = 'BanPlayerRequest'; + @override + late final ClassMapperBase superMapper = + ClientWorldEventMapper.ensureInitialized(); + + static BanPlayerRequest _instantiate(DecodingData data) { + return BanPlayerRequest( + data.dec(_f$player), + reason: data.dec(_f$reason), + expiresAt: data.dec(_f$expiresAt), + ); + } + + @override + final Function instantiate = _instantiate; + + static BanPlayerRequest fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static BanPlayerRequest fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +/// @nodoc +mixin BanPlayerRequestMappable { + String toJson() { + return BanPlayerRequestMapper.ensureInitialized() + .encodeJson(this as BanPlayerRequest); + } + + Map toMap() { + return BanPlayerRequestMapper.ensureInitialized() + .encodeMap(this as BanPlayerRequest); + } + + BanPlayerRequestCopyWith + get copyWith => + _BanPlayerRequestCopyWithImpl( + this as BanPlayerRequest, + $identity, + $identity, + ); + @override + String toString() { + return BanPlayerRequestMapper.ensureInitialized().stringifyValue( + this as BanPlayerRequest, + ); + } + + @override + bool operator ==(Object other) { + return BanPlayerRequestMapper.ensureInitialized().equalsValue( + this as BanPlayerRequest, + other, + ); + } + + @override + int get hashCode { + return BanPlayerRequestMapper.ensureInitialized().hashValue( + this as BanPlayerRequest, + ); + } +} + +/// @nodoc +extension BanPlayerRequestValueCopy<$R, $Out> + on ObjectCopyWith<$R, BanPlayerRequest, $Out> { + BanPlayerRequestCopyWith<$R, BanPlayerRequest, $Out> + get $asBanPlayerRequest => + $base.as((v, t, t2) => _BanPlayerRequestCopyWithImpl<$R, $Out>(v, t, t2)); +} + +/// @nodoc +abstract class BanPlayerRequestCopyWith<$R, $In extends BanPlayerRequest, $Out> + implements ClientWorldEventCopyWith<$R, $In, $Out> { + @override + $R call({int? player, String? reason, DateTime? expiresAt}); + BanPlayerRequestCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ); +} + +/// @nodoc +class _BanPlayerRequestCopyWithImpl<$R, $Out> + extends ClassCopyWithBase<$R, BanPlayerRequest, $Out> + implements BanPlayerRequestCopyWith<$R, BanPlayerRequest, $Out> { + _BanPlayerRequestCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = + BanPlayerRequestMapper.ensureInitialized(); + @override + $R call({int? player, Object? reason = $none, Object? expiresAt = $none}) => + $apply( + FieldCopyWithData({ + if (player != null) #player: player, + if (reason != $none) #reason: reason, + if (expiresAt != $none) #expiresAt: expiresAt, + }), + ); + @override + BanPlayerRequest $make(CopyWithData data) => BanPlayerRequest( + data.get(#player, or: $value.player), + reason: data.get(#reason, or: $value.reason), + expiresAt: data.get(#expiresAt, or: $value.expiresAt), + ); + + @override + BanPlayerRequestCopyWith<$R2, BanPlayerRequest, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ) => _BanPlayerRequestCopyWithImpl<$R2, $Out2>($value, $cast, t); +} + +/// @nodoc +class ServerRoleChangeRequestMapper + extends SubClassMapperBase { + ServerRoleChangeRequestMapper._(); + + static ServerRoleChangeRequestMapper? _instance; + static ServerRoleChangeRequestMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use( + _instance = ServerRoleChangeRequestMapper._(), + ); + ClientWorldEventMapper.ensureInitialized().addSubMapper(_instance!); + } + return _instance!; + } + + @override + final String id = 'ServerRoleChangeRequest'; + + static int _$player(ServerRoleChangeRequest v) => v.player; + static const Field _f$player = Field( + 'player', + _$player, + ); + static String _$role(ServerRoleChangeRequest v) => v.role; + static const Field _f$role = Field( + 'role', + _$role, + ); + + @override + final MappableFields fields = const { + #player: _f$player, + #role: _f$role, + }; + + @override + final String discriminatorKey = 'type'; + @override + final dynamic discriminatorValue = 'ServerRoleChangeRequest'; + @override + late final ClassMapperBase superMapper = + ClientWorldEventMapper.ensureInitialized(); + + static ServerRoleChangeRequest _instantiate(DecodingData data) { + return ServerRoleChangeRequest(data.dec(_f$player), data.dec(_f$role)); + } + + @override + final Function instantiate = _instantiate; + + static ServerRoleChangeRequest fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static ServerRoleChangeRequest fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +/// @nodoc +mixin ServerRoleChangeRequestMappable { + String toJson() { + return ServerRoleChangeRequestMapper.ensureInitialized() + .encodeJson(this as ServerRoleChangeRequest); + } + + Map toMap() { + return ServerRoleChangeRequestMapper.ensureInitialized() + .encodeMap(this as ServerRoleChangeRequest); + } + + ServerRoleChangeRequestCopyWith< + ServerRoleChangeRequest, + ServerRoleChangeRequest, + ServerRoleChangeRequest + > + get copyWith => + _ServerRoleChangeRequestCopyWithImpl< + ServerRoleChangeRequest, + ServerRoleChangeRequest + >(this as ServerRoleChangeRequest, $identity, $identity); + @override + String toString() { + return ServerRoleChangeRequestMapper.ensureInitialized().stringifyValue( + this as ServerRoleChangeRequest, + ); + } + + @override + bool operator ==(Object other) { + return ServerRoleChangeRequestMapper.ensureInitialized().equalsValue( + this as ServerRoleChangeRequest, + other, + ); + } + + @override + int get hashCode { + return ServerRoleChangeRequestMapper.ensureInitialized().hashValue( + this as ServerRoleChangeRequest, + ); + } +} + +/// @nodoc +extension ServerRoleChangeRequestValueCopy<$R, $Out> + on ObjectCopyWith<$R, ServerRoleChangeRequest, $Out> { + ServerRoleChangeRequestCopyWith<$R, ServerRoleChangeRequest, $Out> + get $asServerRoleChangeRequest => $base.as( + (v, t, t2) => _ServerRoleChangeRequestCopyWithImpl<$R, $Out>(v, t, t2), + ); +} + +/// @nodoc +abstract class ServerRoleChangeRequestCopyWith< + $R, + $In extends ServerRoleChangeRequest, + $Out +> + implements ClientWorldEventCopyWith<$R, $In, $Out> { + @override + $R call({int? player, String? role}); + ServerRoleChangeRequestCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ); +} + +/// @nodoc +class _ServerRoleChangeRequestCopyWithImpl<$R, $Out> + extends ClassCopyWithBase<$R, ServerRoleChangeRequest, $Out> + implements + ServerRoleChangeRequestCopyWith<$R, ServerRoleChangeRequest, $Out> { + _ServerRoleChangeRequestCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = + ServerRoleChangeRequestMapper.ensureInitialized(); + @override + $R call({int? player, String? role}) => $apply( + FieldCopyWithData({ + if (player != null) #player: player, + if (role != null) #role: role, + }), + ); + @override + ServerRoleChangeRequest $make(CopyWithData data) => ServerRoleChangeRequest( + data.get(#player, or: $value.player), + data.get(#role, or: $value.role), + ); + + @override + ServerRoleChangeRequestCopyWith<$R2, ServerRoleChangeRequest, $Out2> + $chain<$R2, $Out2>(Then<$Out2, $R2> t) => + _ServerRoleChangeRequestCopyWithImpl<$R2, $Out2>($value, $cast, t); +} + +/// @nodoc +class GameRolesChangeRequestMapper + extends SubClassMapperBase { + GameRolesChangeRequestMapper._(); + + static GameRolesChangeRequestMapper? _instance; + static GameRolesChangeRequestMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = GameRolesChangeRequestMapper._()); + ClientWorldEventMapper.ensureInitialized().addSubMapper(_instance!); + } + return _instance!; + } + + @override + final String id = 'GameRolesChangeRequest'; + + static int _$player(GameRolesChangeRequest v) => v.player; + static const Field _f$player = Field( + 'player', + _$player, + ); + static Set _$roles(GameRolesChangeRequest v) => v.roles; + static const Field> _f$roles = Field( + 'roles', + _$roles, + opt: true, + def: const {}, + ); + + @override + final MappableFields fields = const { + #player: _f$player, + #roles: _f$roles, + }; + + @override + final String discriminatorKey = 'type'; + @override + final dynamic discriminatorValue = 'GameRolesChangeRequest'; + @override + late final ClassMapperBase superMapper = + ClientWorldEventMapper.ensureInitialized(); + + static GameRolesChangeRequest _instantiate(DecodingData data) { + return GameRolesChangeRequest(data.dec(_f$player), data.dec(_f$roles)); + } + + @override + final Function instantiate = _instantiate; + + static GameRolesChangeRequest fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static GameRolesChangeRequest fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +/// @nodoc +mixin GameRolesChangeRequestMappable { + String toJson() { + return GameRolesChangeRequestMapper.ensureInitialized() + .encodeJson(this as GameRolesChangeRequest); + } + + Map toMap() { + return GameRolesChangeRequestMapper.ensureInitialized() + .encodeMap(this as GameRolesChangeRequest); + } + + GameRolesChangeRequestCopyWith< + GameRolesChangeRequest, + GameRolesChangeRequest, + GameRolesChangeRequest + > + get copyWith => + _GameRolesChangeRequestCopyWithImpl< + GameRolesChangeRequest, + GameRolesChangeRequest + >(this as GameRolesChangeRequest, $identity, $identity); + @override + String toString() { + return GameRolesChangeRequestMapper.ensureInitialized().stringifyValue( + this as GameRolesChangeRequest, + ); + } + + @override + bool operator ==(Object other) { + return GameRolesChangeRequestMapper.ensureInitialized().equalsValue( + this as GameRolesChangeRequest, + other, + ); + } + + @override + int get hashCode { + return GameRolesChangeRequestMapper.ensureInitialized().hashValue( + this as GameRolesChangeRequest, + ); + } +} + +/// @nodoc +extension GameRolesChangeRequestValueCopy<$R, $Out> + on ObjectCopyWith<$R, GameRolesChangeRequest, $Out> { + GameRolesChangeRequestCopyWith<$R, GameRolesChangeRequest, $Out> + get $asGameRolesChangeRequest => $base.as( + (v, t, t2) => _GameRolesChangeRequestCopyWithImpl<$R, $Out>(v, t, t2), + ); +} + +/// @nodoc +abstract class GameRolesChangeRequestCopyWith< + $R, + $In extends GameRolesChangeRequest, + $Out +> + implements ClientWorldEventCopyWith<$R, $In, $Out> { + @override + $R call({int? player, Set? roles}); + GameRolesChangeRequestCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ); +} + +/// @nodoc +class _GameRolesChangeRequestCopyWithImpl<$R, $Out> + extends ClassCopyWithBase<$R, GameRolesChangeRequest, $Out> + implements + GameRolesChangeRequestCopyWith<$R, GameRolesChangeRequest, $Out> { + _GameRolesChangeRequestCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = + GameRolesChangeRequestMapper.ensureInitialized(); + @override + $R call({int? player, Set? roles}) => $apply( + FieldCopyWithData({ + if (player != null) #player: player, + if (roles != null) #roles: roles, + }), + ); + @override + GameRolesChangeRequest $make(CopyWithData data) => GameRolesChangeRequest( + data.get(#player, or: $value.player), + data.get(#roles, or: $value.roles), + ); + + @override + GameRolesChangeRequestCopyWith<$R2, GameRolesChangeRequest, $Out2> + $chain<$R2, $Out2>(Then<$Out2, $R2> t) => + _GameRolesChangeRequestCopyWithImpl<$R2, $Out2>($value, $cast, t); +} + /// @nodoc class HybridWorldEventMapper extends SubClassMapperBase { HybridWorldEventMapper._(); diff --git a/api/lib/src/event/process/client.dart b/api/lib/src/event/process/client.dart index 0ba1fc7d..098827db 100644 --- a/api/lib/src/event/process/client.dart +++ b/api/lib/src/event/process/client.dart @@ -212,6 +212,7 @@ Future processClientEvent( .values .toList(), teamMembers: state.teamMembers, + gameRoleMembers: state.gameRoleMembers, ); if (event == null) { @@ -452,6 +453,10 @@ Future processClientEvent( } return UpdateServerResponse.builder(buildInitialize(), channel); case ToolbarActionRequest(): + case KickPlayerRequest(): + case BanPlayerRequest(): + case ServerRoleChangeRequest(): + case GameRolesChangeRequest(): return UpdateServerResponse.builder(null); } } diff --git a/api/lib/src/event/process/server.dart b/api/lib/src/event/process/server.dart index 41a61350..52c92f7f 100644 --- a/api/lib/src/event/process/server.dart +++ b/api/lib/src/event/process/server.dart @@ -13,6 +13,7 @@ bool isValidServerEvent(ServerWorldEvent event, WorldState state) => true), TeamJoined() => state.info.teams.containsKey(event.team), TeamLeft() => state.info.teams.containsKey(event.team), + GameRolesChanged() => event.roles.every((role) => role.isNotEmpty), CellShuffled() => event.positions.every( (e) => e.inRange( 0, @@ -118,6 +119,7 @@ ServerProcessed processServerEvent( table: event.table ?? state.table, id: event.id ?? state.id, teamMembers: event.teamMembers ?? state.teamMembers, + gameRoleMembers: event.gameRoleMembers ?? state.gameRoleMembers, info: event.info ?? state.info, dialogs: event.clearUserInterface ? [] : state.dialogs, images: event.clearUserInterface ? {} : state.images, @@ -144,6 +146,16 @@ ServerProcessed processServerEvent( allMembers[event.team] = members; } return ServerProcessed(state.copyWith(teamMembers: allMembers)); + case GameRolesChanged(): + final members = >{ + for (final entry in state.gameRoleMembers.entries) + if (entry.value.any((member) => member != event.user)) + entry.key: {...entry.value}..remove(event.user), + }; + for (final role in event.roles) { + members.putIfAbsent(role, () => {}).add(event.user); + } + return ServerProcessed(state.copyWith(gameRoleMembers: members)); case ObjectsChanged(): return ServerProcessed( state.mapTableOrDefault(event.cell.table, (table) { diff --git a/api/lib/src/event/server.dart b/api/lib/src/event/server.dart index c73342a4..ab7f09fc 100644 --- a/api/lib/src/event/server.dart +++ b/api/lib/src/event/server.dart @@ -14,6 +14,7 @@ final class WorldInitialized extends ServerWorldEvent final GameTable? table; final GameInfo? info; final Map>? teamMembers; + final Map>? gameRoleMembers; final Channel? id; final List? packsSignature; final bool clearUserInterface; @@ -22,6 +23,7 @@ final class WorldInitialized extends ServerWorldEvent this.table, this.info, this.teamMembers, + this.gameRoleMembers, this.id, this.packsSignature, this.clearUserInterface = false, @@ -38,9 +40,19 @@ final class WorldInitialized extends ServerWorldEvent ), table: mode?.item.tables[state.tableName] ?? GameTable(), teamMembers: const {}, + gameRoleMembers: const {}, ); } +@MappableClass() +final class GameRolesChanged extends ServerWorldEvent + with GameRolesChangedMappable { + final Channel user; + final Set roles; + + const GameRolesChanged(this.user, [this.roles = const {}]); +} + @MappableClass() final class TeamJoined extends ServerWorldEvent with TeamJoinedMappable { final Channel user; diff --git a/api/lib/src/event/state.dart b/api/lib/src/event/state.dart index 1d09917a..cf3fbeda 100644 --- a/api/lib/src/event/state.dart +++ b/api/lib/src/event/state.dart @@ -31,6 +31,7 @@ final class WorldState with WorldStateMappable { final String? name; final Channel id; final Map> teamMembers; + final Map> gameRoleMembers; final FileMetadata metadata; final SetonixData data; final List messages; @@ -49,6 +50,7 @@ final class WorldState with WorldStateMappable { this.info = const GameInfo(), this.metadata = const FileMetadata(), this.teamMembers = const {}, + this.gameRoleMembers = const {}, this.messages = const [], this.id = kAuthorityChannel, this.dialogs = const [], @@ -83,6 +85,11 @@ final class WorldState with WorldStateMappable { .map((entry) => entry.key) .toSet(); + Set getGameRoles([Channel? id]) => gameRoleMembers.entries + .where((entry) => entry.value.contains(id ?? this.id)) + .map((entry) => entry.key) + .toSet(); + TableCell protectCell(VectorDefinition cell, [Channel? id]) { final cellObject = table.getCell(cell); if (cellObject.isEmpty) return cellObject; diff --git a/api/lib/src/event/state.mapper.dart b/api/lib/src/event/state.mapper.dart index 0824e91c..9eb76b83 100644 --- a/api/lib/src/event/state.mapper.dart +++ b/api/lib/src/event/state.mapper.dart @@ -180,6 +180,10 @@ class WorldStateMapper extends ClassMapperBase { opt: true, def: const {}, ); + static Map> _$gameRoleMembers(WorldState v) => + v.gameRoleMembers; + static const Field>> _f$gameRoleMembers = + Field('gameRoleMembers', _$gameRoleMembers, opt: true, def: const {}); static List _$messages(WorldState v) => v.messages; static const Field> _f$messages = Field( 'messages', @@ -240,6 +244,7 @@ class WorldStateMapper extends ClassMapperBase { #info: _f$info, #metadata: _f$metadata, #teamMembers: _f$teamMembers, + #gameRoleMembers: _f$gameRoleMembers, #messages: _f$messages, #id: _f$id, #dialogs: _f$dialogs, @@ -259,6 +264,7 @@ class WorldStateMapper extends ClassMapperBase { info: data.dec(_f$info), metadata: data.dec(_f$metadata), teamMembers: data.dec(_f$teamMembers), + gameRoleMembers: data.dec(_f$gameRoleMembers), messages: data.dec(_f$messages), id: data.dec(_f$id), dialogs: data.dec(_f$dialogs), @@ -338,6 +344,8 @@ abstract class WorldStateCopyWith<$R, $In extends WorldState, $Out> FileMetadataCopyWith<$R, FileMetadata, FileMetadata> get metadata; MapCopyWith<$R, String, Set, ObjectCopyWith<$R, Set, Set>> get teamMembers; + MapCopyWith<$R, String, Set, ObjectCopyWith<$R, Set, Set>> + get gameRoleMembers; ListCopyWith< $R, ChatMessage, @@ -364,6 +372,7 @@ abstract class WorldStateCopyWith<$R, $In extends WorldState, $Out> GameInfo? info, FileMetadata? metadata, Map>? teamMembers, + Map>? gameRoleMembers, List? messages, int? id, List? dialogs, @@ -402,6 +411,13 @@ class _WorldStateCopyWithImpl<$R, $Out> (v) => call(teamMembers: v), ); @override + MapCopyWith<$R, String, Set, ObjectCopyWith<$R, Set, Set>> + get gameRoleMembers => MapCopyWith( + $value.gameRoleMembers, + (v, t) => ObjectCopyWith(v, $identity, t), + (v) => call(gameRoleMembers: v), + ); + @override ListCopyWith< $R, ChatMessage, @@ -449,6 +465,7 @@ class _WorldStateCopyWithImpl<$R, $Out> GameInfo? info, FileMetadata? metadata, Map>? teamMembers, + Map>? gameRoleMembers, List? messages, int? id, List? dialogs, @@ -466,6 +483,7 @@ class _WorldStateCopyWithImpl<$R, $Out> if (info != null) #info: info, if (metadata != null) #metadata: metadata, if (teamMembers != null) #teamMembers: teamMembers, + if (gameRoleMembers != null) #gameRoleMembers: gameRoleMembers, if (messages != null) #messages: messages, if (id != null) #id: id, if (dialogs != null) #dialogs: dialogs, @@ -485,6 +503,7 @@ class _WorldStateCopyWithImpl<$R, $Out> info: data.get(#info, or: $value.info), metadata: data.get(#metadata, or: $value.metadata), teamMembers: data.get(#teamMembers, or: $value.teamMembers), + gameRoleMembers: data.get(#gameRoleMembers, or: $value.gameRoleMembers), messages: data.get(#messages, or: $value.messages), id: data.get(#id, or: $value.id), dialogs: data.get(#dialogs, or: $value.dialogs), diff --git a/api/lib/src/models/authorization.dart b/api/lib/src/models/authorization.dart new file mode 100644 index 00000000..6d9d1c5c --- /dev/null +++ b/api/lib/src/models/authorization.dart @@ -0,0 +1,55 @@ +import 'package:dart_mappable/dart_mappable.dart'; + +part 'authorization.mapper.dart'; + +const kDefaultServerRole = 'player'; + +abstract final class ServerPermission { + static const String play = 'play'; + static const String manageWorld = 'manageWorld'; + static const String kickPlayers = 'kickPlayers'; + static const String banPlayers = 'banPlayers'; + static const String manageRoles = 'manageRoles'; +} + +@MappableClass() +final class ServerRoleDefinition with ServerRoleDefinitionMappable { + final String name; + final Set permissions; + final int priority; + + const ServerRoleDefinition({ + required this.name, + this.permissions = const {}, + this.priority = 0, + }); +} + +const kDefaultServerRoles = { + 'spectator': ServerRoleDefinition(name: 'Spectator'), + 'player': ServerRoleDefinition( + name: 'Player', + permissions: {ServerPermission.play}, + ), + 'moderator': ServerRoleDefinition( + name: 'Moderator', + priority: 50, + permissions: { + ServerPermission.play, + ServerPermission.manageWorld, + ServerPermission.kickPlayers, + ServerPermission.banPlayers, + }, + ), + 'owner': ServerRoleDefinition( + name: 'Owner', + priority: 100, + permissions: { + ServerPermission.play, + ServerPermission.manageWorld, + ServerPermission.kickPlayers, + ServerPermission.banPlayers, + ServerPermission.manageRoles, + }, + ), +}; diff --git a/api/lib/src/models/authorization.mapper.dart b/api/lib/src/models/authorization.mapper.dart new file mode 100644 index 00000000..ea9ff19a --- /dev/null +++ b/api/lib/src/models/authorization.mapper.dart @@ -0,0 +1,169 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +// ignore_for_file: type=lint +// ignore_for_file: invalid_use_of_protected_member +// ignore_for_file: unused_element, unnecessary_cast, override_on_non_overriding_member +// ignore_for_file: strict_raw_type, inference_failure_on_untyped_parameter + +part of 'authorization.dart'; + +/// @nodoc +class ServerRoleDefinitionMapper extends ClassMapperBase { + ServerRoleDefinitionMapper._(); + + static ServerRoleDefinitionMapper? _instance; + static ServerRoleDefinitionMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = ServerRoleDefinitionMapper._()); + } + return _instance!; + } + + @override + final String id = 'ServerRoleDefinition'; + + static String _$name(ServerRoleDefinition v) => v.name; + static const Field _f$name = Field( + 'name', + _$name, + ); + static Set _$permissions(ServerRoleDefinition v) => v.permissions; + static const Field> _f$permissions = Field( + 'permissions', + _$permissions, + opt: true, + def: const {}, + ); + static int _$priority(ServerRoleDefinition v) => v.priority; + static const Field _f$priority = Field( + 'priority', + _$priority, + opt: true, + def: 0, + ); + + @override + final MappableFields fields = const { + #name: _f$name, + #permissions: _f$permissions, + #priority: _f$priority, + }; + + static ServerRoleDefinition _instantiate(DecodingData data) { + return ServerRoleDefinition( + name: data.dec(_f$name), + permissions: data.dec(_f$permissions), + priority: data.dec(_f$priority), + ); + } + + @override + final Function instantiate = _instantiate; + + static ServerRoleDefinition fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static ServerRoleDefinition fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +/// @nodoc +mixin ServerRoleDefinitionMappable { + String toJson() { + return ServerRoleDefinitionMapper.ensureInitialized() + .encodeJson(this as ServerRoleDefinition); + } + + Map toMap() { + return ServerRoleDefinitionMapper.ensureInitialized() + .encodeMap(this as ServerRoleDefinition); + } + + ServerRoleDefinitionCopyWith< + ServerRoleDefinition, + ServerRoleDefinition, + ServerRoleDefinition + > + get copyWith => + _ServerRoleDefinitionCopyWithImpl< + ServerRoleDefinition, + ServerRoleDefinition + >(this as ServerRoleDefinition, $identity, $identity); + @override + String toString() { + return ServerRoleDefinitionMapper.ensureInitialized().stringifyValue( + this as ServerRoleDefinition, + ); + } + + @override + bool operator ==(Object other) { + return ServerRoleDefinitionMapper.ensureInitialized().equalsValue( + this as ServerRoleDefinition, + other, + ); + } + + @override + int get hashCode { + return ServerRoleDefinitionMapper.ensureInitialized().hashValue( + this as ServerRoleDefinition, + ); + } +} + +/// @nodoc +extension ServerRoleDefinitionValueCopy<$R, $Out> + on ObjectCopyWith<$R, ServerRoleDefinition, $Out> { + ServerRoleDefinitionCopyWith<$R, ServerRoleDefinition, $Out> + get $asServerRoleDefinition => $base.as( + (v, t, t2) => _ServerRoleDefinitionCopyWithImpl<$R, $Out>(v, t, t2), + ); +} + +/// @nodoc +abstract class ServerRoleDefinitionCopyWith< + $R, + $In extends ServerRoleDefinition, + $Out +> + implements ClassCopyWith<$R, $In, $Out> { + $R call({String? name, Set? permissions, int? priority}); + ServerRoleDefinitionCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ); +} + +/// @nodoc +class _ServerRoleDefinitionCopyWithImpl<$R, $Out> + extends ClassCopyWithBase<$R, ServerRoleDefinition, $Out> + implements ServerRoleDefinitionCopyWith<$R, ServerRoleDefinition, $Out> { + _ServerRoleDefinitionCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = + ServerRoleDefinitionMapper.ensureInitialized(); + @override + $R call({String? name, Set? permissions, int? priority}) => $apply( + FieldCopyWithData({ + if (name != null) #name: name, + if (permissions != null) #permissions: permissions, + if (priority != null) #priority: priority, + }), + ); + @override + ServerRoleDefinition $make(CopyWithData data) => ServerRoleDefinition( + name: data.get(#name, or: $value.name), + permissions: data.get(#permissions, or: $value.permissions), + priority: data.get(#priority, or: $value.priority), + ); + + @override + ServerRoleDefinitionCopyWith<$R2, ServerRoleDefinition, $Out2> + $chain<$R2, $Out2>(Then<$Out2, $R2> t) => + _ServerRoleDefinitionCopyWithImpl<$R2, $Out2>($value, $cast, t); +} + diff --git a/api/lib/src/models/config.dart b/api/lib/src/models/config.dart index e23c2e8c..85e9685a 100644 --- a/api/lib/src/models/config.dart +++ b/api/lib/src/models/config.dart @@ -47,6 +47,7 @@ final class SetonixConfig with SetonixConfigMappable { final String? gameMode; static const String defaultGameMode = ''; static const String envGameMode = 'SETONIX_GAME_MODE'; + final Map? serverRoles; const SetonixConfig({ this.host, @@ -63,6 +64,7 @@ final class SetonixConfig with SetonixConfigMappable { this.apiEndpoint, this.endpointSecret, this.gameMode, + this.serverRoles, }); static const defaultConfig = SetonixConfig( @@ -80,6 +82,7 @@ final class SetonixConfig with SetonixConfigMappable { apiEndpoint: defaultApiEndpoint, endpointSecret: defaultEndpointSecret, gameMode: defaultGameMode, + serverRoles: kDefaultServerRoles, ); static SetonixConfig fromEnvironment() { @@ -144,6 +147,7 @@ final class SetonixConfig with SetonixConfigMappable { gameMode: bool.hasEnvironment(envGameMode) ? String.fromEnvironment(envGameMode, defaultValue: defaultGameMode) : null, + serverRoles: null, ); } @@ -162,5 +166,6 @@ final class SetonixConfig with SetonixConfigMappable { apiEndpoint: other.apiEndpoint ?? apiEndpoint, endpointSecret: other.endpointSecret ?? endpointSecret, gameMode: other.gameMode ?? gameMode, + serverRoles: other.serverRoles ?? serverRoles, ); } diff --git a/api/lib/src/models/config.mapper.dart b/api/lib/src/models/config.mapper.dart index c509edfe..c05ee528 100644 --- a/api/lib/src/models/config.mapper.dart +++ b/api/lib/src/models/config.mapper.dart @@ -16,6 +16,7 @@ class SetonixConfigMapper extends ClassMapperBase { static SetonixConfigMapper ensureInitialized() { if (_instance == null) { MapperContainer.globals.use(_instance = SetonixConfigMapper._()); + ServerRoleDefinitionMapper.ensureInitialized(); } return _instance!; } @@ -107,6 +108,10 @@ class SetonixConfigMapper extends ClassMapperBase { _$gameMode, opt: true, ); + static Map? _$serverRoles(SetonixConfig v) => + v.serverRoles; + static const Field> + _f$serverRoles = Field('serverRoles', _$serverRoles, opt: true); @override final MappableFields fields = const { @@ -124,6 +129,7 @@ class SetonixConfigMapper extends ClassMapperBase { #apiEndpoint: _f$apiEndpoint, #endpointSecret: _f$endpointSecret, #gameMode: _f$gameMode, + #serverRoles: _f$serverRoles, }; static SetonixConfig _instantiate(DecodingData data) { @@ -142,6 +148,7 @@ class SetonixConfigMapper extends ClassMapperBase { apiEndpoint: data.dec(_f$apiEndpoint), endpointSecret: data.dec(_f$endpointSecret), gameMode: data.dec(_f$gameMode), + serverRoles: data.dec(_f$serverRoles), ); } @@ -210,6 +217,13 @@ extension SetonixConfigValueCopy<$R, $Out> /// @nodoc abstract class SetonixConfigCopyWith<$R, $In extends SetonixConfig, $Out> implements ClassCopyWith<$R, $In, $Out> { + MapCopyWith< + $R, + String, + ServerRoleDefinition, + ServerRoleDefinitionCopyWith<$R, ServerRoleDefinition, ServerRoleDefinition> + >? + get serverRoles; $R call({ String? host, int? port, @@ -225,6 +239,7 @@ abstract class SetonixConfigCopyWith<$R, $In extends SetonixConfig, $Out> String? apiEndpoint, String? endpointSecret, String? gameMode, + Map? serverRoles, }); SetonixConfigCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); } @@ -239,6 +254,20 @@ class _SetonixConfigCopyWithImpl<$R, $Out> late final ClassMapperBase $mapper = SetonixConfigMapper.ensureInitialized(); @override + MapCopyWith< + $R, + String, + ServerRoleDefinition, + ServerRoleDefinitionCopyWith<$R, ServerRoleDefinition, ServerRoleDefinition> + >? + get serverRoles => $value.serverRoles != null + ? MapCopyWith( + $value.serverRoles!, + (v, t) => v.copyWith.$chain(t), + (v) => call(serverRoles: v), + ) + : null; + @override $R call({ Object? host = $none, Object? port = $none, @@ -254,6 +283,7 @@ class _SetonixConfigCopyWithImpl<$R, $Out> Object? apiEndpoint = $none, Object? endpointSecret = $none, Object? gameMode = $none, + Object? serverRoles = $none, }) => $apply( FieldCopyWithData({ if (host != $none) #host: host, @@ -270,6 +300,7 @@ class _SetonixConfigCopyWithImpl<$R, $Out> if (apiEndpoint != $none) #apiEndpoint: apiEndpoint, if (endpointSecret != $none) #endpointSecret: endpointSecret, if (gameMode != $none) #gameMode: gameMode, + if (serverRoles != $none) #serverRoles: serverRoles, }), ); @override @@ -288,6 +319,7 @@ class _SetonixConfigCopyWithImpl<$R, $Out> apiEndpoint: data.get(#apiEndpoint, or: $value.apiEndpoint), endpointSecret: data.get(#endpointSecret, or: $value.endpointSecret), gameMode: data.get(#gameMode, or: $value.gameMode), + serverRoles: data.get(#serverRoles, or: $value.serverRoles), ); @override diff --git a/api/lib/src/models/server.dart b/api/lib/src/models/server.dart index 343b55f2..877e4e4f 100644 --- a/api/lib/src/models/server.dart +++ b/api/lib/src/models/server.dart @@ -2,6 +2,7 @@ import 'package:dart_mappable/dart_mappable.dart'; import 'package:networker/networker.dart'; import '../event/event.dart'; +import 'authorization.dart'; import 'meta.dart'; part 'server.mapper.dart'; @@ -195,16 +196,36 @@ class ListProperty extends GameProperty with ListPropertyMappable { final class PlayerInfo with PlayerInfoMappable { final Channel id; final String? name; - - const PlayerInfo({required this.id, this.name}); + final String serverRole; + final Set gameRoles; + final bool registered; + final bool manageable; + + const PlayerInfo({ + required this.id, + this.name, + this.serverRole = kDefaultServerRole, + this.gameRoles = const {}, + this.registered = false, + this.manageable = false, + }); } @MappableClass() final class ServerState with ServerStateMappable { final String? link; final List players; - - const ServerState({this.link, this.players = const []}); + final Map serverRoles; + final Set permissions; + final Set assignableServerRoles; + + const ServerState({ + this.link, + this.players = const [], + this.serverRoles = const {}, + this.permissions = const {}, + this.assignableServerRoles = const {}, + }); } @MappableClass() diff --git a/api/lib/src/models/server.mapper.dart b/api/lib/src/models/server.mapper.dart index 84a784db..b060813b 100644 --- a/api/lib/src/models/server.mapper.dart +++ b/api/lib/src/models/server.mapper.dart @@ -1416,12 +1416,54 @@ class PlayerInfoMapper extends ClassMapperBase { _$name, opt: true, ); + static String _$serverRole(PlayerInfo v) => v.serverRole; + static const Field _f$serverRole = Field( + 'serverRole', + _$serverRole, + opt: true, + def: kDefaultServerRole, + ); + static Set _$gameRoles(PlayerInfo v) => v.gameRoles; + static const Field> _f$gameRoles = Field( + 'gameRoles', + _$gameRoles, + opt: true, + def: const {}, + ); + static bool _$registered(PlayerInfo v) => v.registered; + static const Field _f$registered = Field( + 'registered', + _$registered, + opt: true, + def: false, + ); + static bool _$manageable(PlayerInfo v) => v.manageable; + static const Field _f$manageable = Field( + 'manageable', + _$manageable, + opt: true, + def: false, + ); @override - final MappableFields fields = const {#id: _f$id, #name: _f$name}; + final MappableFields fields = const { + #id: _f$id, + #name: _f$name, + #serverRole: _f$serverRole, + #gameRoles: _f$gameRoles, + #registered: _f$registered, + #manageable: _f$manageable, + }; static PlayerInfo _instantiate(DecodingData data) { - return PlayerInfo(id: data.dec(_f$id), name: data.dec(_f$name)); + return PlayerInfo( + id: data.dec(_f$id), + name: data.dec(_f$name), + serverRole: data.dec(_f$serverRole), + gameRoles: data.dec(_f$gameRoles), + registered: data.dec(_f$registered), + manageable: data.dec(_f$manageable), + ); } @override @@ -1487,7 +1529,14 @@ extension PlayerInfoValueCopy<$R, $Out> /// @nodoc abstract class PlayerInfoCopyWith<$R, $In extends PlayerInfo, $Out> implements ClassCopyWith<$R, $In, $Out> { - $R call({int? id, String? name}); + $R call({ + int? id, + String? name, + String? serverRole, + Set? gameRoles, + bool? registered, + bool? manageable, + }); PlayerInfoCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); } @@ -1501,16 +1550,31 @@ class _PlayerInfoCopyWithImpl<$R, $Out> late final ClassMapperBase $mapper = PlayerInfoMapper.ensureInitialized(); @override - $R call({int? id, Object? name = $none}) => $apply( + $R call({ + int? id, + Object? name = $none, + String? serverRole, + Set? gameRoles, + bool? registered, + bool? manageable, + }) => $apply( FieldCopyWithData({ if (id != null) #id: id, if (name != $none) #name: name, + if (serverRole != null) #serverRole: serverRole, + if (gameRoles != null) #gameRoles: gameRoles, + if (registered != null) #registered: registered, + if (manageable != null) #manageable: manageable, }), ); @override PlayerInfo $make(CopyWithData data) => PlayerInfo( id: data.get(#id, or: $value.id), name: data.get(#name, or: $value.name), + serverRole: data.get(#serverRole, or: $value.serverRole), + gameRoles: data.get(#gameRoles, or: $value.gameRoles), + registered: data.get(#registered, or: $value.registered), + manageable: data.get(#manageable, or: $value.manageable), ); @override @@ -1528,6 +1592,7 @@ class ServerStateMapper extends ClassMapperBase { if (_instance == null) { MapperContainer.globals.use(_instance = ServerStateMapper._()); PlayerInfoMapper.ensureInitialized(); + ServerRoleDefinitionMapper.ensureInitialized(); } return _instance!; } @@ -1548,15 +1613,48 @@ class ServerStateMapper extends ClassMapperBase { opt: true, def: const [], ); + static Map _$serverRoles(ServerState v) => + v.serverRoles; + static const Field> + _f$serverRoles = Field( + 'serverRoles', + _$serverRoles, + opt: true, + def: const {}, + ); + static Set _$permissions(ServerState v) => v.permissions; + static const Field> _f$permissions = Field( + 'permissions', + _$permissions, + opt: true, + def: const {}, + ); + static Set _$assignableServerRoles(ServerState v) => + v.assignableServerRoles; + static const Field> _f$assignableServerRoles = Field( + 'assignableServerRoles', + _$assignableServerRoles, + opt: true, + def: const {}, + ); @override final MappableFields fields = const { #link: _f$link, #players: _f$players, + #serverRoles: _f$serverRoles, + #permissions: _f$permissions, + #assignableServerRoles: _f$assignableServerRoles, }; static ServerState _instantiate(DecodingData data) { - return ServerState(link: data.dec(_f$link), players: data.dec(_f$players)); + return ServerState( + link: data.dec(_f$link), + players: data.dec(_f$players), + serverRoles: data.dec(_f$serverRoles), + permissions: data.dec(_f$permissions), + assignableServerRoles: data.dec(_f$assignableServerRoles), + ); } @override @@ -1624,7 +1722,20 @@ abstract class ServerStateCopyWith<$R, $In extends ServerState, $Out> implements ClassCopyWith<$R, $In, $Out> { ListCopyWith<$R, PlayerInfo, PlayerInfoCopyWith<$R, PlayerInfo, PlayerInfo>> get players; - $R call({String? link, List? players}); + MapCopyWith< + $R, + String, + ServerRoleDefinition, + ServerRoleDefinitionCopyWith<$R, ServerRoleDefinition, ServerRoleDefinition> + > + get serverRoles; + $R call({ + String? link, + List? players, + Map? serverRoles, + Set? permissions, + Set? assignableServerRoles, + }); ServerStateCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); } @@ -1645,16 +1756,44 @@ class _ServerStateCopyWithImpl<$R, $Out> (v) => call(players: v), ); @override - $R call({Object? link = $none, List? players}) => $apply( + MapCopyWith< + $R, + String, + ServerRoleDefinition, + ServerRoleDefinitionCopyWith<$R, ServerRoleDefinition, ServerRoleDefinition> + > + get serverRoles => MapCopyWith( + $value.serverRoles, + (v, t) => v.copyWith.$chain(t), + (v) => call(serverRoles: v), + ); + @override + $R call({ + Object? link = $none, + List? players, + Map? serverRoles, + Set? permissions, + Set? assignableServerRoles, + }) => $apply( FieldCopyWithData({ if (link != $none) #link: link, if (players != null) #players: players, + if (serverRoles != null) #serverRoles: serverRoles, + if (permissions != null) #permissions: permissions, + if (assignableServerRoles != null) + #assignableServerRoles: assignableServerRoles, }), ); @override ServerState $make(CopyWithData data) => ServerState( link: data.get(#link, or: $value.link), players: data.get(#players, or: $value.players), + serverRoles: data.get(#serverRoles, or: $value.serverRoles), + permissions: data.get(#permissions, or: $value.permissions), + assignableServerRoles: data.get( + #assignableServerRoles, + or: $value.assignableServerRoles, + ), ); @override diff --git a/api/lib/src/models/translation.dart b/api/lib/src/models/translation.dart index a288e172..880cb286 100644 --- a/api/lib/src/models/translation.dart +++ b/api/lib/src/models/translation.dart @@ -16,15 +16,18 @@ class TranslationsStore { }) : translations = Map.fromEntries(translations); PackTranslation getTranslation([String? locale]) => - (locale == null ? translations[kFallbackLocale] : translations[locale]) ?? - translations[getLocale()] ?? + translations[locale ?? getLocale()] ?? + translations[kFallbackLocale] ?? PackTranslation(); T findTranslation( T? Function(PackTranslation) finder, T fallback, [ String? locale, - ]) => finder(getTranslation(locale)) ?? finder(getTranslation()) ?? fallback; + ]) => + finder(getTranslation(locale)) ?? + finder(getTranslation(kFallbackLocale)) ?? + fallback; DeckTranslation getDeckTranslation(String key, [String? locale]) => findTranslation((t) => t.decks[key], DeckTranslation(name: key), locale); @@ -64,6 +67,26 @@ class TranslationsStore { ModeTranslation getModeTranslation(String key, [String? locale]) => findTranslation((t) => t.modes[key], ModeTranslation(name: key), locale); + + RoleTranslation getRoleTranslation( + String key, { + String? locale, + String? fallback, + }) => findTranslation( + (t) => t.roles[key], + RoleTranslation(name: fallback ?? key), + locale, + ); + + PermissionTranslation getPermissionTranslation( + String key, { + String? locale, + String? fallback, + }) => findTranslation( + (t) => t.permissions[key], + PermissionTranslation(name: fallback ?? key), + locale, + ); } @MappableClass() @@ -73,6 +96,8 @@ class PackTranslation with PackTranslationMappable { final Map figures; final Map boards; final Map modes; + final Map roles; + final Map permissions; PackTranslation({ this.decks = const {}, @@ -80,6 +105,8 @@ class PackTranslation with PackTranslationMappable { this.backgrounds = const {}, this.boards = const {}, this.modes = const {}, + this.roles = const {}, + this.permissions = const {}, }); } @@ -132,3 +159,15 @@ class ModeTranslation extends DescriptiveTranslation with ModeTranslationMappable { ModeTranslation({required super.name, super.description}); } + +@MappableClass() +class RoleTranslation extends DescriptiveTranslation + with RoleTranslationMappable { + RoleTranslation({required super.name, super.description}); +} + +@MappableClass() +class PermissionTranslation extends DescriptiveTranslation + with PermissionTranslationMappable { + PermissionTranslation({required super.name, super.description}); +} diff --git a/api/lib/src/models/translation.mapper.dart b/api/lib/src/models/translation.mapper.dart index 9510417d..05c21d6c 100644 --- a/api/lib/src/models/translation.mapper.dart +++ b/api/lib/src/models/translation.mapper.dart @@ -21,6 +21,8 @@ class PackTranslationMapper extends ClassMapperBase { BackgroundTranslationMapper.ensureInitialized(); BoardTranslationMapper.ensureInitialized(); ModeTranslationMapper.ensureInitialized(); + RoleTranslationMapper.ensureInitialized(); + PermissionTranslationMapper.ensureInitialized(); } return _instance!; } @@ -50,6 +52,18 @@ class PackTranslationMapper extends ClassMapperBase { static Map _$modes(PackTranslation v) => v.modes; static const Field> _f$modes = Field('modes', _$modes, opt: true, def: const {}); + static Map _$roles(PackTranslation v) => v.roles; + static const Field> _f$roles = + Field('roles', _$roles, opt: true, def: const {}); + static Map _$permissions(PackTranslation v) => + v.permissions; + static const Field> + _f$permissions = Field( + 'permissions', + _$permissions, + opt: true, + def: const {}, + ); @override final MappableFields fields = const { @@ -58,6 +72,8 @@ class PackTranslationMapper extends ClassMapperBase { #backgrounds: _f$backgrounds, #boards: _f$boards, #modes: _f$modes, + #roles: _f$roles, + #permissions: _f$permissions, }; static PackTranslation _instantiate(DecodingData data) { @@ -67,6 +83,8 @@ class PackTranslationMapper extends ClassMapperBase { backgrounds: data.dec(_f$backgrounds), boards: data.dec(_f$boards), modes: data.dec(_f$modes), + roles: data.dec(_f$roles), + permissions: data.dec(_f$permissions), ); } @@ -174,12 +192,32 @@ abstract class PackTranslationCopyWith<$R, $In extends PackTranslation, $Out> ModeTranslationCopyWith<$R, ModeTranslation, ModeTranslation> > get modes; + MapCopyWith< + $R, + String, + RoleTranslation, + RoleTranslationCopyWith<$R, RoleTranslation, RoleTranslation> + > + get roles; + MapCopyWith< + $R, + String, + PermissionTranslation, + PermissionTranslationCopyWith< + $R, + PermissionTranslation, + PermissionTranslation + > + > + get permissions; $R call({ Map? decks, Map? figures, Map? backgrounds, Map? boards, Map? modes, + Map? roles, + Map? permissions, }); PackTranslationCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>( Then<$Out2, $R2> t, @@ -260,12 +298,42 @@ class _PackTranslationCopyWithImpl<$R, $Out> (v) => call(modes: v), ); @override + MapCopyWith< + $R, + String, + RoleTranslation, + RoleTranslationCopyWith<$R, RoleTranslation, RoleTranslation> + > + get roles => MapCopyWith( + $value.roles, + (v, t) => v.copyWith.$chain(t), + (v) => call(roles: v), + ); + @override + MapCopyWith< + $R, + String, + PermissionTranslation, + PermissionTranslationCopyWith< + $R, + PermissionTranslation, + PermissionTranslation + > + > + get permissions => MapCopyWith( + $value.permissions, + (v, t) => v.copyWith.$chain(t), + (v) => call(permissions: v), + ); + @override $R call({ Map? decks, Map? figures, Map? backgrounds, Map? boards, Map? modes, + Map? roles, + Map? permissions, }) => $apply( FieldCopyWithData({ if (decks != null) #decks: decks, @@ -273,6 +341,8 @@ class _PackTranslationCopyWithImpl<$R, $Out> if (backgrounds != null) #backgrounds: backgrounds, if (boards != null) #boards: boards, if (modes != null) #modes: modes, + if (roles != null) #roles: roles, + if (permissions != null) #permissions: permissions, }), ); @override @@ -282,6 +352,8 @@ class _PackTranslationCopyWithImpl<$R, $Out> backgrounds: data.get(#backgrounds, or: $value.backgrounds), boards: data.get(#boards, or: $value.boards), modes: data.get(#modes, or: $value.modes), + roles: data.get(#roles, or: $value.roles), + permissions: data.get(#permissions, or: $value.permissions), ); @override @@ -443,6 +515,8 @@ class DescriptiveTranslationMapper BackgroundTranslationMapper.ensureInitialized(); BoardTranslationMapper.ensureInitialized(); ModeTranslationMapper.ensureInitialized(); + RoleTranslationMapper.ensureInitialized(); + PermissionTranslationMapper.ensureInitialized(); } return _instance!; } @@ -1289,3 +1363,291 @@ class _ModeTranslationCopyWithImpl<$R, $Out> ) => _ModeTranslationCopyWithImpl<$R2, $Out2>($value, $cast, t); } +/// @nodoc +class RoleTranslationMapper extends ClassMapperBase { + RoleTranslationMapper._(); + + static RoleTranslationMapper? _instance; + static RoleTranslationMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = RoleTranslationMapper._()); + DescriptiveTranslationMapper.ensureInitialized(); + } + return _instance!; + } + + @override + final String id = 'RoleTranslation'; + + static String _$name(RoleTranslation v) => v.name; + static const Field _f$name = Field('name', _$name); + static String? _$description(RoleTranslation v) => v.description; + static const Field _f$description = Field( + 'description', + _$description, + opt: true, + ); + + @override + final MappableFields fields = const { + #name: _f$name, + #description: _f$description, + }; + + static RoleTranslation _instantiate(DecodingData data) { + return RoleTranslation( + name: data.dec(_f$name), + description: data.dec(_f$description), + ); + } + + @override + final Function instantiate = _instantiate; + + static RoleTranslation fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static RoleTranslation fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +/// @nodoc +mixin RoleTranslationMappable { + String toJson() { + return RoleTranslationMapper.ensureInitialized() + .encodeJson(this as RoleTranslation); + } + + Map toMap() { + return RoleTranslationMapper.ensureInitialized().encodeMap( + this as RoleTranslation, + ); + } + + RoleTranslationCopyWith + get copyWith => + _RoleTranslationCopyWithImpl( + this as RoleTranslation, + $identity, + $identity, + ); + @override + String toString() { + return RoleTranslationMapper.ensureInitialized().stringifyValue( + this as RoleTranslation, + ); + } + + @override + bool operator ==(Object other) { + return RoleTranslationMapper.ensureInitialized().equalsValue( + this as RoleTranslation, + other, + ); + } + + @override + int get hashCode { + return RoleTranslationMapper.ensureInitialized().hashValue( + this as RoleTranslation, + ); + } +} + +/// @nodoc +extension RoleTranslationValueCopy<$R, $Out> + on ObjectCopyWith<$R, RoleTranslation, $Out> { + RoleTranslationCopyWith<$R, RoleTranslation, $Out> get $asRoleTranslation => + $base.as((v, t, t2) => _RoleTranslationCopyWithImpl<$R, $Out>(v, t, t2)); +} + +/// @nodoc +abstract class RoleTranslationCopyWith<$R, $In extends RoleTranslation, $Out> + implements DescriptiveTranslationCopyWith<$R, $In, $Out> { + @override + $R call({String? name, String? description}); + RoleTranslationCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ); +} + +/// @nodoc +class _RoleTranslationCopyWithImpl<$R, $Out> + extends ClassCopyWithBase<$R, RoleTranslation, $Out> + implements RoleTranslationCopyWith<$R, RoleTranslation, $Out> { + _RoleTranslationCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = + RoleTranslationMapper.ensureInitialized(); + @override + $R call({String? name, Object? description = $none}) => $apply( + FieldCopyWithData({ + if (name != null) #name: name, + if (description != $none) #description: description, + }), + ); + @override + RoleTranslation $make(CopyWithData data) => RoleTranslation( + name: data.get(#name, or: $value.name), + description: data.get(#description, or: $value.description), + ); + + @override + RoleTranslationCopyWith<$R2, RoleTranslation, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ) => _RoleTranslationCopyWithImpl<$R2, $Out2>($value, $cast, t); +} + +/// @nodoc +class PermissionTranslationMapper + extends ClassMapperBase { + PermissionTranslationMapper._(); + + static PermissionTranslationMapper? _instance; + static PermissionTranslationMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = PermissionTranslationMapper._()); + DescriptiveTranslationMapper.ensureInitialized(); + } + return _instance!; + } + + @override + final String id = 'PermissionTranslation'; + + static String _$name(PermissionTranslation v) => v.name; + static const Field _f$name = Field( + 'name', + _$name, + ); + static String? _$description(PermissionTranslation v) => v.description; + static const Field _f$description = Field( + 'description', + _$description, + opt: true, + ); + + @override + final MappableFields fields = const { + #name: _f$name, + #description: _f$description, + }; + + static PermissionTranslation _instantiate(DecodingData data) { + return PermissionTranslation( + name: data.dec(_f$name), + description: data.dec(_f$description), + ); + } + + @override + final Function instantiate = _instantiate; + + static PermissionTranslation fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static PermissionTranslation fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +/// @nodoc +mixin PermissionTranslationMappable { + String toJson() { + return PermissionTranslationMapper.ensureInitialized() + .encodeJson(this as PermissionTranslation); + } + + Map toMap() { + return PermissionTranslationMapper.ensureInitialized() + .encodeMap(this as PermissionTranslation); + } + + PermissionTranslationCopyWith< + PermissionTranslation, + PermissionTranslation, + PermissionTranslation + > + get copyWith => + _PermissionTranslationCopyWithImpl< + PermissionTranslation, + PermissionTranslation + >(this as PermissionTranslation, $identity, $identity); + @override + String toString() { + return PermissionTranslationMapper.ensureInitialized().stringifyValue( + this as PermissionTranslation, + ); + } + + @override + bool operator ==(Object other) { + return PermissionTranslationMapper.ensureInitialized().equalsValue( + this as PermissionTranslation, + other, + ); + } + + @override + int get hashCode { + return PermissionTranslationMapper.ensureInitialized().hashValue( + this as PermissionTranslation, + ); + } +} + +/// @nodoc +extension PermissionTranslationValueCopy<$R, $Out> + on ObjectCopyWith<$R, PermissionTranslation, $Out> { + PermissionTranslationCopyWith<$R, PermissionTranslation, $Out> + get $asPermissionTranslation => $base.as( + (v, t, t2) => _PermissionTranslationCopyWithImpl<$R, $Out>(v, t, t2), + ); +} + +/// @nodoc +abstract class PermissionTranslationCopyWith< + $R, + $In extends PermissionTranslation, + $Out +> + implements DescriptiveTranslationCopyWith<$R, $In, $Out> { + @override + $R call({String? name, String? description}); + PermissionTranslationCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ); +} + +/// @nodoc +class _PermissionTranslationCopyWithImpl<$R, $Out> + extends ClassCopyWithBase<$R, PermissionTranslation, $Out> + implements PermissionTranslationCopyWith<$R, PermissionTranslation, $Out> { + _PermissionTranslationCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = + PermissionTranslationMapper.ensureInitialized(); + @override + $R call({String? name, Object? description = $none}) => $apply( + FieldCopyWithData({ + if (name != null) #name: name, + if (description != $none) #description: description, + }), + ); + @override + PermissionTranslation $make(CopyWithData data) => PermissionTranslation( + name: data.get(#name, or: $value.name), + description: data.get(#description, or: $value.description), + ); + + @override + PermissionTranslationCopyWith<$R2, PermissionTranslation, $Out2> + $chain<$R2, $Out2>(Then<$Out2, $R2> t) => + _PermissionTranslationCopyWithImpl<$R2, $Out2>($value, $cast, t); +} + diff --git a/api/lib/src/services/user.dart b/api/lib/src/services/user.dart index dcdb6ac0..db830f5b 100644 --- a/api/lib/src/services/user.dart +++ b/api/lib/src/services/user.dart @@ -12,16 +12,27 @@ final class SetonixUser with SetonixUserMappable { final String? fingerprint; final String name; final bool onWhitelist; + final String role; + final bool banned; + final DateTime? bannedUntil; + final String? banReason; final DateTime? createdAt, updatedAt, lastLogin; const SetonixUser({ this.fingerprint, required this.name, this.onWhitelist = false, + this.role = kDefaultServerRole, + this.banned = false, + this.bannedUntil, + this.banReason, this.createdAt, this.updatedAt, this.lastLogin, }); + + bool get isBanned => + banned && (bannedUntil == null || bannedUntil!.isAfter(DateTime.now())); } abstract class UserService { @@ -31,9 +42,15 @@ abstract class UserService { String fingerprint, { String? name, bool? onWhitelist, + String? role, + bool? banned, + DateTime? bannedUntil, + String? banReason, DateTime? lastLogin, bool createIfNotExists = false, }); + + FutureOr close() {} } const kUserReferenceID = '#'; @@ -95,7 +112,13 @@ final class UserManager { throw KickMessage(reason: KickReason.notWhitelisted); } } else { + if (user.role.isEmpty) { + user = user.copyWith(role: kDefaultServerRole); + } name = user.name; + if (user.isBanned) { + throw KickMessage(reason: KickReason.ban, message: user.banReason); + } if (whitelistEnabled && !user.onWhitelist) { throw KickMessage(reason: KickReason.notWhitelisted); } @@ -138,6 +161,53 @@ final class UserManager { return true; } + Future changeRole(String reference, String role) async { + final user = await getUserByReference(reference); + if (user == null) return false; + final fingerprint = user.fingerprint; + if (fingerprint != null) { + final result = await service?.updateUser(fingerprint, role: role); + if (result == false) return false; + } + final entry = _users.entries.firstWhereOrNull( + (entry) => + entry.value == user || + (fingerprint != null && entry.value.fingerprint == fingerprint), + ); + if (entry == null) return fingerprint != null; + _users[entry.key] = entry.value.copyWith(role: role); + return true; + } + + Future changeBan( + String reference, { + required bool banned, + DateTime? until, + String? reason, + }) async { + final user = await getUserByReference(reference); + final fingerprint = user?.fingerprint; + if (user == null || fingerprint == null) return false; + final result = await service?.updateUser( + fingerprint, + banned: banned, + bannedUntil: banned ? until : null, + banReason: banned ? reason : null, + ); + if (result == false) return false; + final entry = _users.entries + .where((entry) => entry.value.fingerprint == fingerprint) + .firstOrNull; + if (entry != null) { + _users[entry.key] = entry.value.copyWith( + banned: banned, + bannedUntil: banned ? until : null, + banReason: banned ? reason : null, + ); + } + return true; + } + Future getUserByReference(String reference) async { if (reference.isEmpty) return null; switch (reference[0]) { diff --git a/api/lib/src/services/user.mapper.dart b/api/lib/src/services/user.mapper.dart index 7b602b8b..f416275f 100644 --- a/api/lib/src/services/user.mapper.dart +++ b/api/lib/src/services/user.mapper.dart @@ -39,6 +39,32 @@ class SetonixUserMapper extends ClassMapperBase { opt: true, def: false, ); + static String _$role(SetonixUser v) => v.role; + static const Field _f$role = Field( + 'role', + _$role, + opt: true, + def: kDefaultServerRole, + ); + static bool _$banned(SetonixUser v) => v.banned; + static const Field _f$banned = Field( + 'banned', + _$banned, + opt: true, + def: false, + ); + static DateTime? _$bannedUntil(SetonixUser v) => v.bannedUntil; + static const Field _f$bannedUntil = Field( + 'bannedUntil', + _$bannedUntil, + opt: true, + ); + static String? _$banReason(SetonixUser v) => v.banReason; + static const Field _f$banReason = Field( + 'banReason', + _$banReason, + opt: true, + ); static DateTime? _$createdAt(SetonixUser v) => v.createdAt; static const Field _f$createdAt = Field( 'createdAt', @@ -63,6 +89,10 @@ class SetonixUserMapper extends ClassMapperBase { #fingerprint: _f$fingerprint, #name: _f$name, #onWhitelist: _f$onWhitelist, + #role: _f$role, + #banned: _f$banned, + #bannedUntil: _f$bannedUntil, + #banReason: _f$banReason, #createdAt: _f$createdAt, #updatedAt: _f$updatedAt, #lastLogin: _f$lastLogin, @@ -73,6 +103,10 @@ class SetonixUserMapper extends ClassMapperBase { fingerprint: data.dec(_f$fingerprint), name: data.dec(_f$name), onWhitelist: data.dec(_f$onWhitelist), + role: data.dec(_f$role), + banned: data.dec(_f$banned), + bannedUntil: data.dec(_f$bannedUntil), + banReason: data.dec(_f$banReason), createdAt: data.dec(_f$createdAt), updatedAt: data.dec(_f$updatedAt), lastLogin: data.dec(_f$lastLogin), @@ -146,6 +180,10 @@ abstract class SetonixUserCopyWith<$R, $In extends SetonixUser, $Out> String? fingerprint, String? name, bool? onWhitelist, + String? role, + bool? banned, + DateTime? bannedUntil, + String? banReason, DateTime? createdAt, DateTime? updatedAt, DateTime? lastLogin, @@ -167,6 +205,10 @@ class _SetonixUserCopyWithImpl<$R, $Out> Object? fingerprint = $none, String? name, bool? onWhitelist, + String? role, + bool? banned, + Object? bannedUntil = $none, + Object? banReason = $none, Object? createdAt = $none, Object? updatedAt = $none, Object? lastLogin = $none, @@ -175,6 +217,10 @@ class _SetonixUserCopyWithImpl<$R, $Out> if (fingerprint != $none) #fingerprint: fingerprint, if (name != null) #name: name, if (onWhitelist != null) #onWhitelist: onWhitelist, + if (role != null) #role: role, + if (banned != null) #banned: banned, + if (bannedUntil != $none) #bannedUntil: bannedUntil, + if (banReason != $none) #banReason: banReason, if (createdAt != $none) #createdAt: createdAt, if (updatedAt != $none) #updatedAt: updatedAt, if (lastLogin != $none) #lastLogin: lastLogin, @@ -185,6 +231,10 @@ class _SetonixUserCopyWithImpl<$R, $Out> fingerprint: data.get(#fingerprint, or: $value.fingerprint), name: data.get(#name, or: $value.name), onWhitelist: data.get(#onWhitelist, or: $value.onWhitelist), + role: data.get(#role, or: $value.role), + banned: data.get(#banned, or: $value.banned), + bannedUntil: data.get(#bannedUntil, or: $value.bannedUntil), + banReason: data.get(#banReason, or: $value.banReason), createdAt: data.get(#createdAt, or: $value.createdAt), updatedAt: data.get(#updatedAt, or: $value.updatedAt), lastLogin: data.get(#lastLogin, or: $value.lastLogin), diff --git a/api/test/game_roles_test.dart b/api/test/game_roles_test.dart new file mode 100644 index 00000000..7df5fd96 --- /dev/null +++ b/api/test/game_roles_test.dart @@ -0,0 +1,32 @@ +import 'package:setonix_api/setonix_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('game roles can be replaced and removed dynamically', () { + final state = WorldState( + data: SetonixData.empty(), + gameRoleMembers: const { + 'dealer': {2}, + 'spectator': {2, 3}, + }, + ); + + final changed = processServerEvent( + const GameRolesChanged(2, {'player'}), + state, + signature: const [], + ).state!; + + expect(changed.getGameRoles(2), {'player'}); + expect(changed.getGameRoles(3), {'spectator'}); + + final removed = processServerEvent( + const GameRolesChanged(2), + changed, + signature: const [], + ).state!; + + expect(removed.getGameRoles(2), isEmpty); + expect(removed.gameRoleMembers.containsKey('player'), isFalse); + }); +} diff --git a/api/test/translation_test.dart b/api/test/translation_test.dart new file mode 100644 index 00000000..3da506ce --- /dev/null +++ b/api/test/translation_test.dart @@ -0,0 +1,30 @@ +import 'package:setonix_api/setonix_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('role and permission translations use locale and English fallback', () { + final store = TranslationsStore( + getLocale: () => 'de', + translations: [ + MapEntry( + 'en', + PackTranslation( + roles: {'dealer': RoleTranslation(name: 'Dealer')}, + permissions: {'deal': PermissionTranslation(name: 'Deal cards')}, + ), + ), + MapEntry( + 'de', + PackTranslation(roles: {'dealer': RoleTranslation(name: 'Geber')}), + ), + ], + ); + + expect(store.getRoleTranslation('dealer').name, 'Geber'); + expect(store.getPermissionTranslation('deal').name, 'Deal cards'); + expect( + store.getPermissionTranslation('shuffle', fallback: 'Shuffle').name, + 'Shuffle', + ); + }); +} diff --git a/app/lib/helpers/asset.dart b/app/lib/helpers/asset.dart index 02b49f73..d23263d9 100644 --- a/app/lib/helpers/asset.dart +++ b/app/lib/helpers/asset.dart @@ -118,6 +118,23 @@ class GameAssetManager extends AssetManager { _loadedTranslations[key] ?? TranslationsStore(getLocale: () => currentLocale); + RoleTranslation getRoleTranslation(String key, {String? fallback}) { + final location = ItemLocation.fromString(key); + return getTranslations( + location.namespace, + ).getRoleTranslation(location.id, fallback: fallback); + } + + PermissionTranslation getPermissionTranslation( + String permission, { + String? fallback, + }) { + final location = ItemLocation.fromString(permission); + return getTranslations( + location.namespace, + ).getPermissionTranslation(location.id, fallback: fallback); + } + void unloadPacks(Iterable keys) { _loadedTranslations.removeWhere((e, _) => keys.contains(e)); for (final key in keys) { diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 8424a0fd..2db6b984 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -15,6 +15,18 @@ "games": "Games", "guest": "Guest", "players": "Players", + "kick": "Kick", + "ban": "Ban", + "reason": "Reason", + "banDuration": "Ban duration", + "permanent": "Permanent", + "oneHour": "One hour", + "oneDay": "One day", + "sevenDays": "Seven days", + "gameRoles": "Game roles", + "commaSeparatedRoles": "Comma-separated roles", + "setServerRole": "Set role: {role}", + "@setServerRole": {"placeholders": {"role": {"type": "String"}}}, "noPlayers": "No players", "defaultPlayerName": "Player #{index}", "@defaultPlayerName": { diff --git a/app/lib/pages/game/error.dart b/app/lib/pages/game/error.dart index f459187c..7edfb898 100644 --- a/app/lib/pages/game/error.dart +++ b/app/lib/pages/game/error.dart @@ -51,8 +51,8 @@ class GameErrorView extends StatelessWidget { ); } else if (error is KickMessage) { final link = error.link; - var respponse = error.message; - if (respponse?.isEmpty ?? true) { + var response = error.message; + if (response?.isEmpty ?? true) { message = switch (error.reason) { KickReason.kick => AppLocalizations.of(context).kicked, KickReason.ban => AppLocalizations.of(context).banned, @@ -67,6 +67,8 @@ class GameErrorView extends StatelessWidget { ).challengeFailed, _ => AppLocalizations.of(context).disconnectedMessage, }; + } else { + message = response!; } content = [ if (link != null) diff --git a/app/lib/pages/game/multiplayer/dialog.dart b/app/lib/pages/game/multiplayer/dialog.dart index bdbba428..b66370f3 100644 --- a/app/lib/pages/game/multiplayer/dialog.dart +++ b/app/lib/pages/game/multiplayer/dialog.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -9,6 +11,8 @@ import 'package:barcode/barcode.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:setonix/api/open.dart'; import 'package:setonix/bloc/multiplayer.dart'; +import 'package:setonix/bloc/world/bloc.dart'; +import 'package:setonix/bloc/world/state.dart'; import 'package:setonix/bloc/settings.dart'; import 'package:setonix/src/generated/i18n/app_localizations.dart'; import 'package:setonix_api/setonix_api.dart'; diff --git a/app/lib/pages/game/multiplayer/view.dart b/app/lib/pages/game/multiplayer/view.dart index 3dcbef78..bd209945 100644 --- a/app/lib/pages/game/multiplayer/view.dart +++ b/app/lib/pages/game/multiplayer/view.dart @@ -5,6 +5,146 @@ class ViewMultiplayerDialog extends StatelessWidget { const ViewMultiplayerDialog({super.key, required this.state}); + Future _moderatePlayer( + BuildContext context, + PlayerInfo player, { + required bool ban, + }) async { + final reasonController = TextEditingController(); + var duration = ban ? const Duration(days: 1) : null; + final confirmed = await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: Text( + ban + ? AppLocalizations.of(context).ban + : AppLocalizations.of(context).kick, + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + player.name ?? + AppLocalizations.of(context).defaultUserName(player.id), + ), + const SizedBox(height: 12), + TextField( + controller: reasonController, + decoration: InputDecoration( + labelText: AppLocalizations.of(context).reason, + ), + ), + if (ban) ...[ + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: duration, + decoration: InputDecoration( + labelText: AppLocalizations.of(context).banDuration, + ), + items: [ + DropdownMenuItem( + value: null, + child: Text(AppLocalizations.of(context).permanent), + ), + DropdownMenuItem( + value: const Duration(hours: 1), + child: Text(AppLocalizations.of(context).oneHour), + ), + DropdownMenuItem( + value: const Duration(days: 1), + child: Text(AppLocalizations.of(context).oneDay), + ), + DropdownMenuItem( + value: const Duration(days: 7), + child: Text(AppLocalizations.of(context).sevenDays), + ), + ], + onChanged: (value) => setState(() => duration = value), + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text( + ban + ? AppLocalizations.of(context).ban + : AppLocalizations.of(context).kick, + ), + ), + ], + ), + ), + ); + final reason = reasonController.text.trim(); + reasonController.dispose(); + if (confirmed != true || !context.mounted) return; + unawaited( + context.read().process( + ban + ? BanPlayerRequest( + player.id, + reason: reason.isEmpty ? null : reason, + expiresAt: duration == null + ? null + : DateTime.now().add(duration!), + ) + : KickPlayerRequest( + player.id, + reason: reason.isEmpty ? null : reason, + ), + ), + ); + } + + Future _changeGameRoles( + BuildContext context, + PlayerInfo player, + Set currentRoles, + ) async { + final controller = TextEditingController(text: currentRoles.join(', ')); + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context).gameRoles), + content: TextField( + controller: controller, + decoration: InputDecoration( + labelText: AppLocalizations.of(context).commaSeparatedRoles, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(MaterialLocalizations.of(context).saveButtonLabel), + ), + ], + ), + ); + final roles = controller.text + .split(',') + .map((role) => role.trim()) + .where((role) => role.isNotEmpty) + .toSet(); + controller.dispose(); + if (confirmed != true || !context.mounted) return; + unawaited( + context.read().process( + GameRolesChangeRequest(player.id, roles), + ), + ); + } + @override Widget build(BuildContext context) { final info = FutureBuilder( @@ -63,28 +203,180 @@ class ViewMultiplayerDialog extends StatelessWidget { ); final userList = StreamBuilder>( stream: state.clientChange, - builder: (context, snapshot) { - final connections = snapshot.data ?? {}; - if (connections.isEmpty) { - return Text( - AppLocalizations.of(context).noConnections, - textAlign: TextAlign.center, - ); - } - return ListView.builder( - itemCount: connections.length, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) { - final channel = connections.elementAt(index); - final defaultName = AppLocalizations.of( - context, - ).defaultUserName(channel); - - return ListTile(title: Text(defaultName)); - }, - ); - }, + initialData: context.read().clients, + builder: (context, connectionSnapshot) => + BlocBuilder( + buildWhen: (previous, current) => + previous.world.serverState != current.world.serverState || + previous.world.gameRoleMembers != current.world.gameRoleMembers, + builder: (context, worldState) { + final serverState = worldState.world.serverState; + final players = serverState.players.isNotEmpty + ? serverState.players + : state.isServer + ? (connectionSnapshot.data ?? const {}) + .map( + (channel) => PlayerInfo( + id: channel, + name: state.userManager.getUser(channel)?.name, + ), + ) + .toList(growable: false) + : const []; + if (players.isEmpty) { + return Text( + AppLocalizations.of(context).noConnections, + textAlign: TextAlign.center, + ); + } + return ListView.builder( + itemCount: players.length, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + final player = players[index]; + final gameRoles = worldState.world.getGameRoles(player.id); + final roleDefinition = + serverState.serverRoles[player.serverRole]; + final roleName = worldState.assetManager + .getRoleTranslation( + player.serverRole, + fallback: roleDefinition?.name, + ) + .name; + final gameRoleNames = gameRoles + .map( + (role) => worldState.assetManager + .getRoleTranslation(role) + .name, + ) + .toList(growable: false); + final canKick = + player.manageable && + serverState.permissions.contains( + ServerPermission.kickPlayers, + ); + final canBan = + player.registered && + player.manageable && + serverState.permissions.contains( + ServerPermission.banPlayers, + ); + final canManageRoles = + player.manageable && + serverState.permissions.contains( + ServerPermission.manageRoles, + ); + final canManageGameRoles = serverState.permissions.contains( + ServerPermission.manageWorld, + ); + final canManage = + canKick || canBan || canManageRoles || canManageGameRoles; + return ListTile( + title: Text( + player.name ?? + AppLocalizations.of( + context, + ).defaultUserName(player.id), + ), + subtitle: Text( + [ + roleName, + if (gameRoleNames.isNotEmpty) gameRoleNames.join(', '), + ].join(' · '), + ), + trailing: canManage + ? PopupMenuButton( + onSelected: (value) { + if (value == 'kick') { + _moderatePlayer(context, player, ban: false); + } else if (value == 'ban') { + _moderatePlayer(context, player, ban: true); + } else if (value == 'gameRoles') { + _changeGameRoles(context, player, gameRoles); + } else if (value.startsWith('role:')) { + unawaited( + context.read().process( + ServerRoleChangeRequest( + player.id, + value.substring(5), + ), + ), + ); + } + }, + itemBuilder: (context) => [ + if (canKick) + PopupMenuItem( + value: 'kick', + child: Text( + AppLocalizations.of(context).kick, + ), + ), + if (canBan) + PopupMenuItem( + value: 'ban', + child: Text(AppLocalizations.of(context).ban), + ), + if (canManageGameRoles) + PopupMenuItem( + value: 'gameRoles', + child: Text( + AppLocalizations.of(context).gameRoles, + ), + ), + if (canManageRoles) + ...serverState.serverRoles.entries + .where( + (entry) => serverState + .assignableServerRoles + .contains(entry.key), + ) + .map((entry) { + final translatedRole = worldState + .assetManager + .getRoleTranslation( + entry.key, + fallback: entry.value.name, + ) + .name; + final translatedPermissions = entry + .value + .permissions + .map( + (permission) => worldState + .assetManager + .getPermissionTranslation( + permission, + ) + .name, + ) + .join(', '); + return PopupMenuItem( + value: 'role:${entry.key}', + child: ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + title: Text( + AppLocalizations.of( + context, + ).setServerRole(translatedRole), + ), + subtitle: + translatedPermissions.isEmpty + ? null + : Text(translatedPermissions), + ), + ); + }), + ], + ) + : null, + ); + }, + ); + }, + ), ); final size = MediaQuery.sizeOf(context); final isMobile = size.width < LeapBreakpoints.medium; diff --git a/app/pack/translations/en.json b/app/pack/translations/en.json index b8e64aa0..8bbd0d3c 100644 --- a/app/pack/translations/en.json +++ b/app/pack/translations/en.json @@ -1,4 +1,35 @@ { + "roles": { + "spectator": { + "name": "Spectator" + }, + "player": { + "name": "Player" + }, + "moderator": { + "name": "Moderator" + }, + "owner": { + "name": "Owner" + } + }, + "permissions": { + "play": { + "name": "Play" + }, + "manageWorld": { + "name": "Manage world" + }, + "kickPlayers": { + "name": "Kick players" + }, + "banPlayers": { + "name": "Ban players" + }, + "manageRoles": { + "name": "Manage roles" + } + }, "decks": { "doko": { "name": "Dummkopf", diff --git a/plugin/lib/src/plugin.dart b/plugin/lib/src/plugin.dart index 915b1190..0a9543b1 100644 --- a/plugin/lib/src/plugin.dart +++ b/plugin/lib/src/plugin.dart @@ -276,6 +276,7 @@ final class RustSetonixPlugin extends SetonixPlugin { StateFieldAccess.tableName => jsonEncode(state.tableName), StateFieldAccess.players => jsonEncode(server.players), StateFieldAccess.teamMembers => jsonEncode(state.teamMembers), + StateFieldAccess.gameRoles => jsonEncode(state.gameRoleMembers), StateFieldAccess.game => jsonEncode(location?.id ?? 'unknown'), StateFieldAccess.namespace => jsonEncode( location?.namespace ?? 'unknown', diff --git a/plugin/lib/src/rust/api/plugin.dart b/plugin/lib/src/rust/api/plugin.dart index de7c817e..2d21dad3 100644 --- a/plugin/lib/src/rust/api/plugin.dart +++ b/plugin/lib/src/rust/api/plugin.dart @@ -85,6 +85,7 @@ enum StateFieldAccess { info, players, teamMembers, + gameRoles, namespace, game, } diff --git a/plugin/rust/src/api/plugin.rs b/plugin/rust/src/api/plugin.rs index 252ddc82..8352c62e 100644 --- a/plugin/rust/src/api/plugin.rs +++ b/plugin/rust/src/api/plugin.rs @@ -17,6 +17,7 @@ pub enum StateFieldAccess { Info, Players, TeamMembers, + GameRoles, Namespace, Game, } diff --git a/plugin/rust/src/frb_generated.rs b/plugin/rust/src/frb_generated.rs index fe6344bc..778aa581 100644 --- a/plugin/rust/src/frb_generated.rs +++ b/plugin/rust/src/frb_generated.rs @@ -751,8 +751,9 @@ impl SseDecode for crate::api::plugin::StateFieldAccess { 2 => crate::api::plugin::StateFieldAccess::Info, 3 => crate::api::plugin::StateFieldAccess::Players, 4 => crate::api::plugin::StateFieldAccess::TeamMembers, - 5 => crate::api::plugin::StateFieldAccess::Namespace, - 6 => crate::api::plugin::StateFieldAccess::Game, + 5 => crate::api::plugin::StateFieldAccess::GameRoles, + 6 => crate::api::plugin::StateFieldAccess::Namespace, + 7 => crate::api::plugin::StateFieldAccess::Game, _ => unreachable!("Invalid variant for StateFieldAccess: {}", inner), }; } @@ -877,8 +878,9 @@ impl flutter_rust_bridge::IntoDart for crate::api::plugin::StateFieldAccess { Self::Info => 2.into_dart(), Self::Players => 3.into_dart(), Self::TeamMembers => 4.into_dart(), - Self::Namespace => 5.into_dart(), - Self::Game => 6.into_dart(), + Self::GameRoles => 5.into_dart(), + Self::Namespace => 6.into_dart(), + Self::Game => 7.into_dart(), _ => unreachable!(), } } @@ -1089,8 +1091,9 @@ impl SseEncode for crate::api::plugin::StateFieldAccess { crate::api::plugin::StateFieldAccess::Info => 2, crate::api::plugin::StateFieldAccess::Players => 3, crate::api::plugin::StateFieldAccess::TeamMembers => 4, - crate::api::plugin::StateFieldAccess::Namespace => 5, - crate::api::plugin::StateFieldAccess::Game => 6, + crate::api::plugin::StateFieldAccess::GameRoles => 5, + crate::api::plugin::StateFieldAccess::Namespace => 6, + crate::api::plugin::StateFieldAccess::Game => 7, _ => { unimplemented!(""); } diff --git a/server/README.md b/server/README.md index ff4a1d8d..a4d68a63 100644 --- a/server/README.md +++ b/server/README.md @@ -64,3 +64,39 @@ Future onLoad(SetonixServer server) async { }); } ``` + +### Custom roles and permissions + +Role and permission identifiers are open strings. Use `namespace:key` for +values owned by a custom server or pack. Built-in identifiers remain +unqualified for compatibility and resolve against the `core` pack. + +```json +{ + "serverRoles": { + "blackjack:dealer": { + "name": "Dealer", + "priority": 25, + "permissions": ["play", "blackjack:deal", "blackjack:shuffle"] + } + } +} +``` + +Custom server code can check a permission with +`roleAllowsPermission(role, 'blackjack:deal', roles)`. + +Role and permission labels use the regular pack translation files. For the +example above, add this to `translations/en.json` in the `blackjack` pack: + +```json +{ + "roles": { + "dealer": { "name": "Dealer" } + }, + "permissions": { + "deal": { "name": "Deal cards" }, + "shuffle": { "name": "Shuffle cards" } + } +} +``` diff --git a/server/lib/setonix_server.dart b/server/lib/setonix_server.dart index cc7ad2d2..c9c4ce16 100644 --- a/server/lib/setonix_server.dart +++ b/server/lib/setonix_server.dart @@ -4,6 +4,7 @@ library; export 'package:setonix_api/setonix_api.dart'; export 'package:setonix_plugin/setonix_plugin.dart'; export 'src/asset.dart'; +export 'src/authorization.dart'; export 'src/bloc.dart'; export 'src/main.dart'; export 'src/server.dart'; diff --git a/server/lib/src/authorization.dart b/server/lib/src/authorization.dart new file mode 100644 index 00000000..d2ca3dcd --- /dev/null +++ b/server/lib/src/authorization.dart @@ -0,0 +1,50 @@ +import 'package:setonix_api/setonix_api.dart'; + +String? requiredPermission(WorldEvent event) => switch (event) { + AuthenticateRequest() || + MessageRequest() || + ImagesRequest() || + DialogCloseRequest() => null, + PacksChangeRequest() || + ModeChangeRequest() || + MetadataChanged() || + BackgroundChanged() || + TeamChanged() || + TeamRemoved() || + TableRenamed() || + TableRemoved() => ServerPermission.manageWorld, + KickPlayerRequest() => ServerPermission.kickPlayers, + BanPlayerRequest() => ServerPermission.banPlayers, + ServerRoleChangeRequest() => ServerPermission.manageRoles, + GameRolesChangeRequest() => ServerPermission.manageWorld, + _ => ServerPermission.play, +}; + +Set permissionsForRole( + String role, + Map roles, +) => roles[role]?.permissions ?? const {}; + +bool roleAllowsPermission( + String role, + String? permission, + Map roles, +) => permission == null || permissionsForRole(role, roles).contains(permission); + +bool canProcessClientEvent( + String role, + WorldEvent event, + Map roles, +) => roleAllowsPermission(role, requiredPermission(event), roles); + +bool canManageServerRole( + String actorRole, + String targetRole, + Map roles, +) => (roles[actorRole]?.priority ?? 0) > (roles[targetRole]?.priority ?? 0); + +bool canAssignServerRole( + String actorRole, + String newRole, + Map roles, +) => (roles[actorRole]?.priority ?? 0) >= (roles[newRole]?.priority ?? 0); diff --git a/server/lib/src/bloc.dart b/server/lib/src/bloc.dart index 7f36c63f..62879803 100644 --- a/server/lib/src/bloc.dart +++ b/server/lib/src/bloc.dart @@ -201,6 +201,25 @@ class WorldBloc extends Bloc bool force = false, }) async { final data = packet.data; + if (!packet.isServer && + data is ClientWorldEvent && + data is! AuthenticateRequest) { + final user = server.userManager.getUser(packet.channel); + if (user == null || + !canProcessClientEvent( + user.role, + data, + server.configManager.serverRoles, + )) { + server.log( + 'Rejected ${data.runtimeType} from channel ${packet.channel}: ' + '${user == null ? 'unauthenticated' : 'role ${user.role}'}', + level: LogLevel.warning, + ); + return; + } + if (await _handleManagementEvent(data, packet.channel)) return; + } ServerResponse? process; try { process = await processClientEvent( @@ -253,6 +272,77 @@ class WorldBloc extends Bloc case KickServerResponse(): server.kick(packet.channel, process.message); } + if (data is UserJoined || data is AuthenticateRequest) { + await server.broadcastServerState(this); + } + } + + Future _handleManagementEvent( + ClientWorldEvent event, + Channel actorId, + ) async { + final actor = server.userManager.getUser(actorId); + final targetId = switch (event) { + KickPlayerRequest() => event.player, + BanPlayerRequest() => event.player, + ServerRoleChangeRequest() => event.player, + GameRolesChangeRequest() => event.player, + _ => null, + }; + final target = targetId == null + ? null + : server.userManager.getUser(targetId); + final targetIsInWorld = + targetId != null && + target != null && + server.getUserWorld(targetId) == this; + final roles = server.configManager.serverRoles; + switch (event) { + case KickPlayerRequest(): + if (actor == null || + !targetIsInWorld || + !canManageServerRole(actor.role, target.role, roles)) { + return true; + } + server.kick( + event.player, + KickMessage(reason: KickReason.kick, message: event.reason), + ); + case BanPlayerRequest(): + if (actor == null || + !targetIsInWorld || + !canManageServerRole(actor.role, target.role, roles)) { + return true; + } + final changed = await server.userManager.changeBan( + '#${event.player}', + banned: true, + until: event.expiresAt, + reason: event.reason, + ); + if (changed) { + server.kick( + event.player, + KickMessage(reason: KickReason.ban, message: event.reason), + ); + } + case ServerRoleChangeRequest(): + if (actor == null || + !targetIsInWorld || + !roles.containsKey(event.role) || + !canManageServerRole(actor.role, target.role, roles) || + !canAssignServerRole(actor.role, event.role, roles)) { + return true; + } + await server.userManager.changeRole('#${event.player}', event.role); + await server.broadcastServerState(this); + case GameRolesChangeRequest(): + if (!targetIsInWorld) return true; + await sendEvent(GameRolesChanged(event.player, event.roles)); + default: + return false; + } + return true; } @override @@ -267,7 +357,9 @@ class WorldBloc extends Bloc }) => server.sendEvent(event, target: target, worldName: worldName); @override - List get players => server.players.keys.toList(growable: false); + List get players => server.players.keys + .where((channel) => server.getUserWorldName(channel) == worldName) + .toList(growable: false); @override String getScriptState(String plugin) => _scriptStates[plugin] ?? '{}'; diff --git a/server/lib/src/config.dart b/server/lib/src/config.dart index 2568ad03..56b912ae 100644 --- a/server/lib/src/config.dart +++ b/server/lib/src/config.dart @@ -74,6 +74,9 @@ class ConfigManager { String get endpointSecret => _mergedConfig.endpointSecret ?? SetonixConfig.defaultEndpointSecret; + Map get serverRoles => + _mergedConfig.serverRoles ?? kDefaultServerRoles; + ItemLocation? get gameMode { final data = _mergedConfig.gameMode ?? SetonixConfig.defaultGameMode; if (data.isEmpty) return null; diff --git a/server/lib/src/programs/ban.dart b/server/lib/src/programs/ban.dart new file mode 100644 index 00000000..eeb922f2 --- /dev/null +++ b/server/lib/src/programs/ban.dart @@ -0,0 +1,58 @@ +import 'package:consoler/consoler.dart'; +import 'package:setonix_api/setonix_api.dart'; +import 'package:setonix_server/src/server.dart'; + +class BanProgram extends ConsoleProgram { + final SetonixServer server; + final bool banned; + + BanProgram(this.server, {required this.banned}); + + @override + String getUsage() => banned ? ' [Minutes] [Reason...]' : ''; + + @override + String getDescription() => banned + ? 'Persistently ban a registered user, optionally until a given time.' + : 'Remove a persistent user ban.'; + + @override + Future run(String label, List args) async { + if (args.isEmpty || (!banned && args.length != 1)) { + server.log('Wrong usage, use ${getUsage()}', level: LogLevel.error); + return; + } + final channel = await server.userManager.getUserIdByReference(args.first); + final minutes = args.length > 1 ? int.tryParse(args[1]) : null; + final reasonStart = minutes == null ? 1 : 2; + final reason = args.length > reasonStart + ? args.skip(reasonStart).join(' ') + : null; + final until = minutes == null + ? null + : DateTime.now().add(Duration(minutes: minutes)); + final changed = await server.userManager.changeBan( + args.first, + banned: banned, + until: until, + reason: reason, + ); + if (!changed) { + server.log( + 'User not found or has no fingerprint: ${args.first}', + level: LogLevel.error, + ); + return; + } + if (banned && channel != null) { + server.kick( + channel, + KickMessage(reason: KickReason.ban, message: reason), + ); + } + server.log( + '${banned ? 'Banned' : 'Unbanned'} ${args.first}', + level: LogLevel.info, + ); + } +} diff --git a/server/lib/src/programs/players.dart b/server/lib/src/programs/players.dart index bde3945a..c64d1ca1 100644 --- a/server/lib/src/programs/players.dart +++ b/server/lib/src/programs/players.dart @@ -17,7 +17,7 @@ class PlayersProgram extends ConsoleProgram { for (final player in players) { final connectionInfo = server.players[player.key]; print( - "${connectionInfo?.address} | #${player.key}) | @${player.value.name} | *${player.value.fingerprint ?? 'No Fingerprint'}", + "${connectionInfo?.address} | #${player.key}) | @${player.value.name} | ${player.value.role} | *${player.value.fingerprint ?? 'No Fingerprint'}", ); } print("-----"); diff --git a/server/lib/src/programs/role.dart b/server/lib/src/programs/role.dart new file mode 100644 index 00000000..93338901 --- /dev/null +++ b/server/lib/src/programs/role.dart @@ -0,0 +1,33 @@ +import 'package:consoler/consoler.dart'; +import 'package:setonix_server/src/server.dart'; + +class RoleProgram extends ConsoleProgram { + final SetonixServer server; + + RoleProgram(this.server); + + @override + String getUsage() => ' '; + + @override + String getDescription() => 'Set the authorization role of a user.'; + + @override + Future run(String label, List args) async { + if (args.length != 2) { + server.log('Wrong usage, use ${getUsage()}', level: LogLevel.error); + return; + } + final role = args[1]; + if (!server.configManager.serverRoles.containsKey(role)) { + server.log('Unknown role: ${args[1]}', level: LogLevel.error); + return; + } + if (!await server.userManager.changeRole(args[0], role)) { + server.log('User not found: ${args[0]}', level: LogLevel.error); + return; + } + await server.broadcastAllServerStates(); + server.log('Changed ${args[0]} role to $role', level: LogLevel.info); + } +} diff --git a/server/lib/src/server.dart b/server/lib/src/server.dart index c6295555..b29978a6 100644 --- a/server/lib/src/server.dart +++ b/server/lib/src/server.dart @@ -7,12 +7,15 @@ import 'package:networker_socket/server.dart'; import 'package:setonix_api/setonix_api.dart'; import 'package:setonix_plugin/native.dart'; import 'package:setonix_server/src/asset.dart'; +import 'package:setonix_server/src/authorization.dart'; import 'package:setonix_server/src/bloc.dart'; import 'package:setonix_server/src/config.dart'; import 'package:setonix_server/src/programs/kick.dart'; +import 'package:setonix_server/src/programs/ban.dart'; import 'package:setonix_server/src/programs/packs.dart'; import 'package:setonix_server/src/programs/players.dart'; import 'package:setonix_server/src/programs/reset.dart'; +import 'package:setonix_server/src/programs/role.dart'; import 'package:setonix_server/src/programs/save.dart'; import 'package:setonix_server/src/programs/say.dart'; import 'package:setonix_server/src/programs/scripts.dart'; @@ -103,6 +106,57 @@ final class SetonixServer { WorldState? getUserWorldState(Channel channel) => getUserWorld(channel)?.state; + ServerState buildServerState(Channel viewer, WorldBloc world) { + final viewerRole = userManager.getUser(viewer)?.role ?? ''; + return ServerState( + players: userManager + .getUsers() + .where((entry) => getUserWorld(entry.key) == world) + .map( + (entry) => PlayerInfo( + id: entry.key, + name: entry.value.name, + serverRole: entry.value.role, + gameRoles: world.state.getGameRoles(entry.key), + registered: entry.value.fingerprint != null, + manageable: canManageServerRole( + viewerRole, + entry.value.role, + configManager.serverRoles, + ), + ), + ) + .toList(growable: false), + serverRoles: configManager.serverRoles, + permissions: permissionsForRole(viewerRole, configManager.serverRoles), + assignableServerRoles: configManager.serverRoles.keys + .where( + (role) => canAssignServerRole( + viewerRole, + role, + configManager.serverRoles, + ), + ) + .toSet(), + ); + } + + Future broadcastServerState(WorldBloc world) async { + await Future.wait( + world.players.map( + (channel) => sendEvent( + ServerStateUpdated(buildServerState(channel, world)), + target: channel, + worldName: world.worldName, + ), + ), + ); + } + + Future broadcastAllServerStates() async { + await Future.wait(_worlds.values.map(broadcastServerState)); + } + SetonixServer._( this.consoler, this.assetManager, @@ -299,7 +353,10 @@ final class SetonixServer { 'players': PlayersProgram(this), 'say': SayProgram(this), 'reset': ResetProgram(this), + 'role': RoleProgram(this), 'kick': KickProgram(this), + 'ban': BanProgram(this, banned: true), + 'unban': BanProgram(this, banned: false), 'whitelist': WhitelistProgram(this), 'scripts': ScriptsProgram(this), null: UnknownProgram(), @@ -378,11 +435,16 @@ final class SetonixServer { void _onLeave((Channel, ConnectionInfo) event) { final (user, info) = event; log('${info.address} ($user) left the game', level: LogLevel.info); - getUserWorld(user)?.eventSystem.runLeaveCallback(event.$1, event.$2); + final world = getUserWorld(user); + world?.eventSystem.runLeaveCallback(event.$1, event.$2); + if (world != null && world.state.getGameRoles(user).isNotEmpty) { + unawaited(sendEvent(GameRolesChanged(user), worldName: world.worldName)); + } _userWorlds.remove(user); userManager.removeUser(user); challengeManager?.removeChallenge(user); + unawaited(broadcastAllServerStates()); } Future loadWorlds() async { @@ -444,6 +506,7 @@ final class SetonixServer { } log('Closing...', level: LogLevel.info); _server?.close(); + await userManager.service?.close(); consoler.dispose(); } diff --git a/server/lib/src/services/user/file.dart b/server/lib/src/services/user/file.dart index 4017931b..715c364c 100644 --- a/server/lib/src/services/user/file.dart +++ b/server/lib/src/services/user/file.dart @@ -33,6 +33,14 @@ final class FileUserService extends UserService { fingerprint: row['fingerprint'] as String?, name: row['name'] as String, onWhitelist: row['on_whitelist'] == 1, + role: (row['role'] as String?)?.isNotEmpty == true + ? row['role'] as String + : kDefaultServerRole, + banned: row['banned'] == 1, + bannedUntil: row['banned_until'] != null + ? DateTime.fromMillisecondsSinceEpoch(row['banned_until'] as int) + : null, + banReason: row['ban_reason'] as String?, createdAt: row['created_at'] != null ? DateTime.fromMillisecondsSinceEpoch(row['created_at'] as int) : null, @@ -62,6 +70,10 @@ final class FileUserService extends UserService { String fingerprint, { String? name, bool? onWhitelist, + String? role, + bool? banned, + DateTime? bannedUntil, + String? banReason, DateTime? lastLogin, bool createIfNotExists = false, }) { @@ -76,6 +88,18 @@ final class FileUserService extends UserService { updates.add('on_whitelist = ?'); values.add(onWhitelist ? 1 : 0); } + if (role != null) { + updates.add('role = ?'); + values.add(role); + } + if (banned != null) { + updates.addAll(['banned = ?', 'banned_until = ?', 'ban_reason = ?']); + values.addAll([ + banned ? 1 : 0, + bannedUntil?.millisecondsSinceEpoch, + banReason, + ]); + } if (lastLogin != null) { updates.add('last_login = ?'); values.add(lastLogin.millisecondsSinceEpoch); @@ -103,6 +127,18 @@ final class FileUserService extends UserService { insertCols.add('on_whitelist'); insertVals.add(onWhitelist ? 1 : 0); } + if (role != null) { + insertCols.add('role'); + insertVals.add(role); + } + if (banned != null) { + insertCols.addAll(['banned', 'banned_until', 'ban_reason']); + insertVals.addAll([ + banned ? 1 : 0, + bannedUntil?.millisecondsSinceEpoch, + banReason, + ]); + } if (lastLogin != null) { insertCols.add('last_login'); insertVals.add(lastLogin.millisecondsSinceEpoch); @@ -127,4 +163,10 @@ final class FileUserService extends UserService { } return true; } + + @override + void close() { + _database?.close(); + _database = null; + } } diff --git a/server/lib/src/services/user/migrations.dart b/server/lib/src/services/user/migrations.dart index 97728ef7..8814a3a0 100644 --- a/server/lib/src/services/user/migrations.dart +++ b/server/lib/src/services/user/migrations.dart @@ -10,5 +10,10 @@ CREATE TABLE users ( name TEXT NOT NULL UNIQUE, on_whitelist INTEGER NOT NULL DEFAULT 0 ); +''', + 2: ''' +ALTER TABLE users ADD COLUMN banned INTEGER NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN banned_until INTEGER; +ALTER TABLE users ADD COLUMN ban_reason TEXT; ''', }; diff --git a/server/lib/src/services/user/remote.dart b/server/lib/src/services/user/remote.dart index 492efcc7..66b803e3 100644 --- a/server/lib/src/services/user/remote.dart +++ b/server/lib/src/services/user/remote.dart @@ -51,12 +51,20 @@ final class RemoteUserService extends UserService { String fingerprint, { String? name, bool? onWhitelist, + String? role, + bool? banned, + DateTime? bannedUntil, + String? banReason, DateTime? lastLogin, bool createIfNotExists = false, }) async { final body = jsonEncode({ 'name': name, 'onWhitelist': onWhitelist, + 'role': role, + 'banned': banned, + 'bannedUntil': bannedUntil?.millisecondsSinceEpoch, + 'banReason': banReason, 'lastLogin': lastLogin?.millisecondsSinceEpoch, }); final response = await http @@ -75,6 +83,10 @@ final class RemoteUserService extends UserService { 'fingerprint': fingerprint, 'name': name, 'onWhitelist': onWhitelist, + 'role': role, + 'banned': banned, + 'bannedUntil': bannedUntil?.millisecondsSinceEpoch, + 'banReason': banReason, 'lastLogin': lastLogin?.millisecondsSinceEpoch, }); final createResponse = await http diff --git a/server/test/authorization_test.dart b/server/test/authorization_test.dart new file mode 100644 index 00000000..613e7899 --- /dev/null +++ b/server/test/authorization_test.dart @@ -0,0 +1,131 @@ +import 'package:setonix_api/setonix_api.dart'; +import 'package:setonix_server/src/authorization.dart'; +import 'package:test/test.dart'; + +void main() { + const roles = kDefaultServerRoles; + + group('server authorization', () { + test('spectators can observe and chat but cannot mutate the world', () { + expect( + canProcessClientEvent('spectator', MessageRequest('hello'), roles), + isTrue, + ); + expect( + canProcessClientEvent( + 'spectator', + CellRollRequest(GlobalVectorDefinition('', 0, 0)), + roles, + ), + isFalse, + ); + }); + + test('players can play but cannot change world configuration', () { + expect( + canProcessClientEvent('player', TeamJoinRequest('players'), roles), + isTrue, + ); + expect( + canProcessClientEvent('player', PacksChangeRequest(const []), roles), + isFalse, + ); + expect( + canProcessClientEvent('player', ModeChangeRequest(null), roles), + isFalse, + ); + }); + + test('moderators and owners can manage world configuration', () { + final event = PacksChangeRequest(const []); + + expect(canProcessClientEvent('moderator', event, roles), isTrue); + expect(canProcessClientEvent('owner', event, roles), isTrue); + }); + + test('new unclassified client events default to play permission', () { + final event = ToolbarActionRequest('test'); + + expect(requiredPermission(event), ServerPermission.play); + expect(canProcessClientEvent('spectator', event, roles), isFalse); + expect(canProcessClientEvent('player', event, roles), isTrue); + }); + + test('custom server roles are capability-driven', () { + const deal = 'blackjack:deal'; + const customRoles = { + 'dealer': ServerRoleDefinition( + name: 'Dealer', + permissions: { + ServerPermission.play, + ServerPermission.manageWorld, + deal, + }, + ), + }; + + expect( + canProcessClientEvent( + 'dealer', + PacksChangeRequest(const []), + customRoles, + ), + isTrue, + ); + expect( + canProcessClientEvent('dealer', KickPlayerRequest(2), customRoles), + isFalse, + ); + expect(roleAllowsPermission('dealer', deal, customRoles), isTrue); + expect( + roleAllowsPermission('dealer', 'blackjack:shuffle', customRoles), + isFalse, + ); + }); + + test('role hierarchy prevents managing peers and higher roles', () { + expect(canManageServerRole('moderator', 'player', roles), isTrue); + expect(canManageServerRole('moderator', 'moderator', roles), isFalse); + expect(canManageServerRole('moderator', 'owner', roles), isFalse); + expect(canManageServerRole('owner', 'moderator', roles), isTrue); + }); + + test('roles can only assign roles at or below their priority', () { + expect(canAssignServerRole('moderator', 'moderator', roles), isTrue); + expect(canAssignServerRole('moderator', 'owner', roles), isFalse); + expect(canAssignServerRole('owner', 'owner', roles), isTrue); + }); + + test('custom roles survive server configuration serialization', () { + const config = SetonixConfig( + serverRoles: { + 'blackjack:dealer': ServerRoleDefinition( + name: 'Blackjack dealer', + priority: 25, + permissions: { + ServerPermission.play, + ServerPermission.manageWorld, + 'blackjack:deal', + }, + ), + }, + ); + + final decoded = SetonixConfigMapper.fromMap(config.toMap()); + final dealer = decoded.serverRoles?['blackjack:dealer']; + + expect(dealer?.name, 'Blackjack dealer'); + expect(dealer?.priority, 25); + expect(dealer?.permissions, { + ServerPermission.play, + ServerPermission.manageWorld, + 'blackjack:deal', + }); + expect(config.toMap()['serverRoles']['blackjack:dealer']['permissions'], [ + 'play', + 'manageWorld', + 'blackjack:deal', + ]); + }); + }); +} diff --git a/server/test/user_role_test.dart b/server/test/user_role_test.dart new file mode 100644 index 00000000..bfd73c64 --- /dev/null +++ b/server/test/user_role_test.dart @@ -0,0 +1,62 @@ +import 'dart:io'; + +import 'package:setonix_api/setonix_api.dart'; +import 'package:setonix_server/src/services/user/file.dart'; +import 'package:test/test.dart'; + +void main() { + test('file user service persists and updates roles', () async { + final directory = await Directory.systemTemp.createTemp( + 'setonix-user-role-', + ); + final service = FileUserService(); + addTearDown(() async { + service.close(); + await directory.delete(recursive: true); + }); + await service.setup(rootPath: directory.path); + + expect( + service.updateUser( + 'fingerprint', + name: 'player', + role: 'spectator', + createIfNotExists: true, + ), + isTrue, + ); + expect(service.getUser('fingerprint')?.role, 'spectator'); + + expect(service.updateUser('fingerprint', role: 'moderator'), isTrue); + expect(service.getUser('fingerprint')?.role, 'moderator'); + + expect(service.updateUser('fingerprint', role: ''), isTrue); + expect(service.getUser('fingerprint')?.role, kDefaultServerRole); + }); + + test('custom role identifiers are preserved', () { + final user = SetonixUserMapper.fromMap({ + 'name': 'dealer', + 'role': 'blackjack-dealer', + }); + + expect(user.role, 'blackjack-dealer'); + }); + + test('ban state supports permanent and expiring bans', () { + const permanent = SetonixUser( + name: 'permanent', + banned: true, + banReason: 'abuse', + ); + final expired = SetonixUser( + name: 'expired', + banned: true, + bannedUntil: DateTime.now().subtract(const Duration(minutes: 1)), + ); + + expect(permanent.isBanned, isTrue); + expect(permanent.banReason, 'abuse'); + expect(expired.isBanned, isFalse); + }); +} From ee170c61abffdc5071d580540c024a11f21d2b4a Mon Sep 17 00:00:00 2001 From: CodeDoctorDE Date: Mon, 10 Aug 2026 21:29:19 +0200 Subject: [PATCH 2/8] Add multiple roles support --- api/lib/src/event/client.dart | 11 +- api/lib/src/event/event.mapper.dart | 27 ++- api/lib/src/models/server.dart | 4 +- api/lib/src/models/server.mapper.dart | 22 +-- api/lib/src/services/user.dart | 50 +++++- api/lib/src/services/user.mapper.dart | 22 +-- api/test/roles_test.dart | 29 ++++ app/lib/pages/game/drawer.dart | 8 +- app/lib/pages/game/multiplayer/view.dart | 43 +++-- server/README.md | 33 +++- server/lib/setonix_server.dart | 2 + server/lib/src/authorization.dart | 45 ++++- server/lib/src/bloc.dart | 38 ++++- server/lib/src/config.dart | 18 +- server/lib/src/programs/players.dart | 2 +- server/lib/src/programs/role.dart | 64 ++++++- server/lib/src/programs/roles.dart | 166 +++++++++++++++++++ server/lib/src/server.dart | 16 +- server/lib/src/services/user/file.dart | 50 ++++-- server/lib/src/services/user/migrations.dart | 4 + server/lib/src/services/user/remote.dart | 31 +++- server/test/authorization_test.dart | 62 +++++-- server/test/config_role_test.dart | 39 +++++ server/test/user_role_test.dart | 63 ++++++- 24 files changed, 715 insertions(+), 134 deletions(-) create mode 100644 api/test/roles_test.dart create mode 100644 server/lib/src/programs/roles.dart create mode 100644 server/test/config_role_test.dart diff --git a/api/lib/src/event/client.dart b/api/lib/src/event/client.dart index 214ce205..a8e81653 100644 --- a/api/lib/src/event/client.dart +++ b/api/lib/src/event/client.dart @@ -223,9 +223,16 @@ final class BanPlayerRequest extends ClientWorldEvent final class ServerRoleChangeRequest extends ClientWorldEvent with ServerRoleChangeRequestMappable { final Channel player; - final String role; + final Set roles; + final String? role; + + ServerRoleChangeRequest(this.player, {this.roles = const {}, this.role}); - ServerRoleChangeRequest(this.player, this.role); + Set get effectiveRoles => { + kDefaultServerRole, + ...roles, + if (role?.isNotEmpty == true) role!, + }; } @MappableClass() diff --git a/api/lib/src/event/event.mapper.dart b/api/lib/src/event/event.mapper.dart index 54236d98..3a3d4342 100644 --- a/api/lib/src/event/event.mapper.dart +++ b/api/lib/src/event/event.mapper.dart @@ -5122,15 +5122,24 @@ class ServerRoleChangeRequestMapper 'player', _$player, ); - static String _$role(ServerRoleChangeRequest v) => v.role; + static Set _$roles(ServerRoleChangeRequest v) => v.roles; + static const Field> _f$roles = Field( + 'roles', + _$roles, + opt: true, + def: const {}, + ); + static String? _$role(ServerRoleChangeRequest v) => v.role; static const Field _f$role = Field( 'role', _$role, + opt: true, ); @override final MappableFields fields = const { #player: _f$player, + #roles: _f$roles, #role: _f$role, }; @@ -5143,7 +5152,11 @@ class ServerRoleChangeRequestMapper ClientWorldEventMapper.ensureInitialized(); static ServerRoleChangeRequest _instantiate(DecodingData data) { - return ServerRoleChangeRequest(data.dec(_f$player), data.dec(_f$role)); + return ServerRoleChangeRequest( + data.dec(_f$player), + roles: data.dec(_f$roles), + role: data.dec(_f$role), + ); } @override @@ -5220,7 +5233,7 @@ abstract class ServerRoleChangeRequestCopyWith< > implements ClientWorldEventCopyWith<$R, $In, $Out> { @override - $R call({int? player, String? role}); + $R call({int? player, Set? roles, String? role}); ServerRoleChangeRequestCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>( Then<$Out2, $R2> t, ); @@ -5237,16 +5250,18 @@ class _ServerRoleChangeRequestCopyWithImpl<$R, $Out> late final ClassMapperBase $mapper = ServerRoleChangeRequestMapper.ensureInitialized(); @override - $R call({int? player, String? role}) => $apply( + $R call({int? player, Set? roles, Object? role = $none}) => $apply( FieldCopyWithData({ if (player != null) #player: player, - if (role != null) #role: role, + if (roles != null) #roles: roles, + if (role != $none) #role: role, }), ); @override ServerRoleChangeRequest $make(CopyWithData data) => ServerRoleChangeRequest( data.get(#player, or: $value.player), - data.get(#role, or: $value.role), + roles: data.get(#roles, or: $value.roles), + role: data.get(#role, or: $value.role), ); @override diff --git a/api/lib/src/models/server.dart b/api/lib/src/models/server.dart index 877e4e4f..b700f035 100644 --- a/api/lib/src/models/server.dart +++ b/api/lib/src/models/server.dart @@ -196,7 +196,7 @@ class ListProperty extends GameProperty with ListPropertyMappable { final class PlayerInfo with PlayerInfoMappable { final Channel id; final String? name; - final String serverRole; + final Set serverRoles; final Set gameRoles; final bool registered; final bool manageable; @@ -204,7 +204,7 @@ final class PlayerInfo with PlayerInfoMappable { const PlayerInfo({ required this.id, this.name, - this.serverRole = kDefaultServerRole, + this.serverRoles = const {kDefaultServerRole}, this.gameRoles = const {}, this.registered = false, this.manageable = false, diff --git a/api/lib/src/models/server.mapper.dart b/api/lib/src/models/server.mapper.dart index b060813b..93e59022 100644 --- a/api/lib/src/models/server.mapper.dart +++ b/api/lib/src/models/server.mapper.dart @@ -1416,12 +1416,12 @@ class PlayerInfoMapper extends ClassMapperBase { _$name, opt: true, ); - static String _$serverRole(PlayerInfo v) => v.serverRole; - static const Field _f$serverRole = Field( - 'serverRole', - _$serverRole, + static Set _$serverRoles(PlayerInfo v) => v.serverRoles; + static const Field> _f$serverRoles = Field( + 'serverRoles', + _$serverRoles, opt: true, - def: kDefaultServerRole, + def: const {kDefaultServerRole}, ); static Set _$gameRoles(PlayerInfo v) => v.gameRoles; static const Field> _f$gameRoles = Field( @@ -1449,7 +1449,7 @@ class PlayerInfoMapper extends ClassMapperBase { final MappableFields fields = const { #id: _f$id, #name: _f$name, - #serverRole: _f$serverRole, + #serverRoles: _f$serverRoles, #gameRoles: _f$gameRoles, #registered: _f$registered, #manageable: _f$manageable, @@ -1459,7 +1459,7 @@ class PlayerInfoMapper extends ClassMapperBase { return PlayerInfo( id: data.dec(_f$id), name: data.dec(_f$name), - serverRole: data.dec(_f$serverRole), + serverRoles: data.dec(_f$serverRoles), gameRoles: data.dec(_f$gameRoles), registered: data.dec(_f$registered), manageable: data.dec(_f$manageable), @@ -1532,7 +1532,7 @@ abstract class PlayerInfoCopyWith<$R, $In extends PlayerInfo, $Out> $R call({ int? id, String? name, - String? serverRole, + Set? serverRoles, Set? gameRoles, bool? registered, bool? manageable, @@ -1553,7 +1553,7 @@ class _PlayerInfoCopyWithImpl<$R, $Out> $R call({ int? id, Object? name = $none, - String? serverRole, + Set? serverRoles, Set? gameRoles, bool? registered, bool? manageable, @@ -1561,7 +1561,7 @@ class _PlayerInfoCopyWithImpl<$R, $Out> FieldCopyWithData({ if (id != null) #id: id, if (name != $none) #name: name, - if (serverRole != null) #serverRole: serverRole, + if (serverRoles != null) #serverRoles: serverRoles, if (gameRoles != null) #gameRoles: gameRoles, if (registered != null) #registered: registered, if (manageable != null) #manageable: manageable, @@ -1571,7 +1571,7 @@ class _PlayerInfoCopyWithImpl<$R, $Out> PlayerInfo $make(CopyWithData data) => PlayerInfo( id: data.get(#id, or: $value.id), name: data.get(#name, or: $value.name), - serverRole: data.get(#serverRole, or: $value.serverRole), + serverRoles: data.get(#serverRoles, or: $value.serverRoles), gameRoles: data.get(#gameRoles, or: $value.gameRoles), registered: data.get(#registered, or: $value.registered), manageable: data.get(#manageable, or: $value.manageable), diff --git a/api/lib/src/services/user.dart b/api/lib/src/services/user.dart index db830f5b..5b90b850 100644 --- a/api/lib/src/services/user.dart +++ b/api/lib/src/services/user.dart @@ -12,7 +12,7 @@ final class SetonixUser with SetonixUserMappable { final String? fingerprint; final String name; final bool onWhitelist; - final String role; + final Set roles; final bool banned; final DateTime? bannedUntil; final String? banReason; @@ -22,7 +22,7 @@ final class SetonixUser with SetonixUserMappable { this.fingerprint, required this.name, this.onWhitelist = false, - this.role = kDefaultServerRole, + this.roles = const {kDefaultServerRole}, this.banned = false, this.bannedUntil, this.banReason, @@ -42,7 +42,7 @@ abstract class UserService { String fingerprint, { String? name, bool? onWhitelist, - String? role, + Set? roles, bool? banned, DateTime? bannedUntil, String? banReason, @@ -50,6 +50,8 @@ abstract class UserService { bool createIfNotExists = false, }); + FutureOr replaceRole(String role, String replacement) => false; + FutureOr close() {} } @@ -112,8 +114,8 @@ final class UserManager { throw KickMessage(reason: KickReason.notWhitelisted); } } else { - if (user.role.isEmpty) { - user = user.copyWith(role: kDefaultServerRole); + if (!user.roles.contains(kDefaultServerRole)) { + user = user.copyWith(roles: {kDefaultServerRole, ...user.roles}); } name = user.name; if (user.isBanned) { @@ -161,12 +163,13 @@ final class UserManager { return true; } - Future changeRole(String reference, String role) async { + Future changeRoles(String reference, Set roles) async { + roles = {...roles, kDefaultServerRole}; final user = await getUserByReference(reference); if (user == null) return false; final fingerprint = user.fingerprint; if (fingerprint != null) { - final result = await service?.updateUser(fingerprint, role: role); + final result = await service?.updateUser(fingerprint, roles: roles); if (result == false) return false; } final entry = _users.entries.firstWhereOrNull( @@ -175,10 +178,41 @@ final class UserManager { (fingerprint != null && entry.value.fingerprint == fingerprint), ); if (entry == null) return fingerprint != null; - _users[entry.key] = entry.value.copyWith(role: role); + _users[entry.key] = entry.value.copyWith(roles: roles); return true; } + Future addRole(String reference, String role) async { + final user = await getUserByReference(reference); + if (user == null) return false; + return changeRoles(reference, {...user.roles, role}); + } + + Future removeRole(String reference, String role) async { + if (role == kDefaultServerRole) return false; + final user = await getUserByReference(reference); + if (user == null || !user.roles.contains(role)) return false; + return changeRoles(reference, {...user.roles}..remove(role)); + } + + Future replaceRole(String role, String replacement) async { + final replacedPersistently = await service?.replaceRole(role, replacement); + final entries = _users.entries + .where((entry) => entry.value.roles.contains(role)) + .toList(growable: false); + for (final entry in entries) { + final fingerprint = entry.value.fingerprint; + if (replacedPersistently != true && fingerprint != null) { + final updatedRoles = {...entry.value.roles, replacement}..remove(role); + await service?.updateUser(fingerprint, roles: updatedRoles); + } + _users[entry.key] = entry.value.copyWith( + roles: {...entry.value.roles, replacement}..remove(role), + ); + } + return entries.length; + } + Future changeBan( String reference, { required bool banned, diff --git a/api/lib/src/services/user.mapper.dart b/api/lib/src/services/user.mapper.dart index f416275f..fbb6891e 100644 --- a/api/lib/src/services/user.mapper.dart +++ b/api/lib/src/services/user.mapper.dart @@ -39,12 +39,12 @@ class SetonixUserMapper extends ClassMapperBase { opt: true, def: false, ); - static String _$role(SetonixUser v) => v.role; - static const Field _f$role = Field( - 'role', - _$role, + static Set _$roles(SetonixUser v) => v.roles; + static const Field> _f$roles = Field( + 'roles', + _$roles, opt: true, - def: kDefaultServerRole, + def: const {kDefaultServerRole}, ); static bool _$banned(SetonixUser v) => v.banned; static const Field _f$banned = Field( @@ -89,7 +89,7 @@ class SetonixUserMapper extends ClassMapperBase { #fingerprint: _f$fingerprint, #name: _f$name, #onWhitelist: _f$onWhitelist, - #role: _f$role, + #roles: _f$roles, #banned: _f$banned, #bannedUntil: _f$bannedUntil, #banReason: _f$banReason, @@ -103,7 +103,7 @@ class SetonixUserMapper extends ClassMapperBase { fingerprint: data.dec(_f$fingerprint), name: data.dec(_f$name), onWhitelist: data.dec(_f$onWhitelist), - role: data.dec(_f$role), + roles: data.dec(_f$roles), banned: data.dec(_f$banned), bannedUntil: data.dec(_f$bannedUntil), banReason: data.dec(_f$banReason), @@ -180,7 +180,7 @@ abstract class SetonixUserCopyWith<$R, $In extends SetonixUser, $Out> String? fingerprint, String? name, bool? onWhitelist, - String? role, + Set? roles, bool? banned, DateTime? bannedUntil, String? banReason, @@ -205,7 +205,7 @@ class _SetonixUserCopyWithImpl<$R, $Out> Object? fingerprint = $none, String? name, bool? onWhitelist, - String? role, + Set? roles, bool? banned, Object? bannedUntil = $none, Object? banReason = $none, @@ -217,7 +217,7 @@ class _SetonixUserCopyWithImpl<$R, $Out> if (fingerprint != $none) #fingerprint: fingerprint, if (name != null) #name: name, if (onWhitelist != null) #onWhitelist: onWhitelist, - if (role != null) #role: role, + if (roles != null) #roles: roles, if (banned != null) #banned: banned, if (bannedUntil != $none) #bannedUntil: bannedUntil, if (banReason != $none) #banReason: banReason, @@ -231,7 +231,7 @@ class _SetonixUserCopyWithImpl<$R, $Out> fingerprint: data.get(#fingerprint, or: $value.fingerprint), name: data.get(#name, or: $value.name), onWhitelist: data.get(#onWhitelist, or: $value.onWhitelist), - role: data.get(#role, or: $value.role), + roles: data.get(#roles, or: $value.roles), banned: data.get(#banned, or: $value.banned), bannedUntil: data.get(#bannedUntil, or: $value.bannedUntil), banReason: data.get(#banReason, or: $value.banReason), diff --git a/api/test/roles_test.dart b/api/test/roles_test.dart new file mode 100644 index 00000000..aa36fa34 --- /dev/null +++ b/api/test/roles_test.dart @@ -0,0 +1,29 @@ +import 'package:setonix_api/setonix_api.dart'; +import 'package:test/test.dart'; + +void main() { + test('users serialize multiple server roles', () { + const user = SetonixUser( + name: 'Dealer', + roles: {'player', 'moderator', 'blackjack:dealer'}, + ); + + final decoded = SetonixUserMapper.fromJson(user.toJson()); + + expect(decoded.roles, {'player', 'moderator', 'blackjack:dealer'}); + }); + + test( + 'role change requests retain player and accept legacy role payloads', + () { + final current = ServerRoleChangeRequest(2, roles: {'moderator'}); + final legacy = ServerRoleChangeRequestMapper.fromMap({ + 'player': 2, + 'role': 'moderator', + }); + + expect(current.effectiveRoles, {'player', 'moderator'}); + expect(legacy.effectiveRoles, {'player', 'moderator'}); + }, + ); +} diff --git a/app/lib/pages/game/drawer.dart b/app/lib/pages/game/drawer.dart index ed3fd180..e60fc428 100644 --- a/app/lib/pages/game/drawer.dart +++ b/app/lib/pages/game/drawer.dart @@ -437,10 +437,14 @@ class GameDrawer extends StatelessWidget { onTap: () { Scaffold.of(context).closeDrawer(); final multiplayer = context.read(); + final world = context.read(); showDialog( context: context, - builder: (context) => BlocProvider.value( - value: multiplayer, + builder: (context) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: multiplayer), + BlocProvider.value(value: world), + ], child: const MultiplayerDialog(), ), ); diff --git a/app/lib/pages/game/multiplayer/view.dart b/app/lib/pages/game/multiplayer/view.dart index bd209945..2da54f05 100644 --- a/app/lib/pages/game/multiplayer/view.dart +++ b/app/lib/pages/game/multiplayer/view.dart @@ -236,14 +236,16 @@ class ViewMultiplayerDialog extends StatelessWidget { itemBuilder: (context, index) { final player = players[index]; final gameRoles = worldState.world.getGameRoles(player.id); - final roleDefinition = - serverState.serverRoles[player.serverRole]; - final roleName = worldState.assetManager - .getRoleTranslation( - player.serverRole, - fallback: roleDefinition?.name, + final roleNames = player.serverRoles + .map( + (role) => worldState.assetManager + .getRoleTranslation( + role, + fallback: serverState.serverRoles[role]?.name, + ) + .name, ) - .name; + .toList(growable: false); final gameRoleNames = gameRoles .map( (role) => worldState.assetManager @@ -281,7 +283,7 @@ class ViewMultiplayerDialog extends StatelessWidget { ), subtitle: Text( [ - roleName, + roleNames.join(', '), if (gameRoleNames.isNotEmpty) gameRoleNames.join(', '), ].join(' · '), ), @@ -295,11 +297,21 @@ class ViewMultiplayerDialog extends StatelessWidget { } else if (value == 'gameRoles') { _changeGameRoles(context, player, gameRoles); } else if (value.startsWith('role:')) { + final role = value.substring(5); + final updatedRoles = Set.from( + player.serverRoles, + ); + if (updatedRoles.contains(role) && + role != kDefaultServerRole) { + updatedRoles.remove(role); + } else { + updatedRoles.add(role); + } unawaited( context.read().process( ServerRoleChangeRequest( player.id, - value.substring(5), + roles: updatedRoles, ), ), ); @@ -352,16 +364,17 @@ class ViewMultiplayerDialog extends StatelessWidget { .name, ) .join(', '); - return PopupMenuItem( + return CheckedPopupMenuItem( value: 'role:${entry.key}', + checked: player.serverRoles.contains( + entry.key, + ), + enabled: + entry.key != kDefaultServerRole, child: ListTile( contentPadding: EdgeInsets.zero, dense: true, - title: Text( - AppLocalizations.of( - context, - ).setServerRole(translatedRole), - ), + title: Text(translatedRole), subtitle: translatedPermissions.isEmpty ? null diff --git a/server/README.md b/server/README.md index a4d68a63..91cf1181 100644 --- a/server/README.md +++ b/server/README.md @@ -83,8 +83,37 @@ unqualified for compatibility and resolve against the `core` pack. } ``` -Custom server code can check a permission with -`roleAllowsPermission(role, 'blackjack:deal', roles)`. +Custom server code can check effective user permissions with +`rolesAllowPermission(user.roles, 'blackjack:deal', roles)`. + +Users may have multiple roles. Their effective permissions are the union of +all assigned roles, and their highest role priority is used for moderation +hierarchy checks. The `player` role is always retained. + +Role assignments can be managed from the server console: + +```text +role list +role add +role remove +role set +``` + +Role definitions are managed persistently with `roles`: + +```text +roles list +roles show +roles create +roles delete [replacement] +roles rename +roles priority +roles permission add +roles permission remove +``` + +Deleting a role replaces it with `player` unless another replacement is +provided. The default `player` role cannot be deleted. Role and permission labels use the regular pack translation files. For the example above, add this to `translations/en.json` in the `blackjack` pack: diff --git a/server/lib/setonix_server.dart b/server/lib/setonix_server.dart index c9c4ce16..503925a5 100644 --- a/server/lib/setonix_server.dart +++ b/server/lib/setonix_server.dart @@ -11,6 +11,8 @@ export 'src/server.dart'; export 'src/programs/kick.dart'; export 'src/programs/packs.dart'; export 'src/programs/players.dart'; +export 'src/programs/role.dart'; +export 'src/programs/roles.dart'; export 'src/programs/reset.dart'; export 'src/programs/save.dart'; export 'src/programs/say.dart'; diff --git a/server/lib/src/authorization.dart b/server/lib/src/authorization.dart index d2ca3dcd..368080ef 100644 --- a/server/lib/src/authorization.dart +++ b/server/lib/src/authorization.dart @@ -25,26 +25,55 @@ Set permissionsForRole( Map roles, ) => roles[role]?.permissions ?? const {}; +Set permissionsForRoles( + Iterable assignedRoles, + Map roles, +) => assignedRoles.expand((role) => permissionsForRole(role, roles)).toSet(); + bool roleAllowsPermission( String role, String? permission, Map roles, ) => permission == null || permissionsForRole(role, roles).contains(permission); +bool rolesAllowPermission( + Iterable assignedRoles, + String? permission, + Map roles, +) => + permission == null || + permissionsForRoles(assignedRoles, roles).contains(permission); + bool canProcessClientEvent( - String role, + Iterable assignedRoles, WorldEvent event, Map roles, -) => roleAllowsPermission(role, requiredPermission(event), roles); +) => rolesAllowPermission(assignedRoles, requiredPermission(event), roles); + +int highestRolePriority( + Iterable assignedRoles, + Map roles, +) { + int? highest; + for (final role in assignedRoles) { + final priority = roles[role]?.priority; + if (priority != null && (highest == null || priority > highest)) { + highest = priority; + } + } + return highest ?? 0; +} -bool canManageServerRole( - String actorRole, - String targetRole, +bool canManageServerRoles( + Iterable actorRoles, + Iterable targetRoles, Map roles, -) => (roles[actorRole]?.priority ?? 0) > (roles[targetRole]?.priority ?? 0); +) => + highestRolePriority(actorRoles, roles) > + highestRolePriority(targetRoles, roles); bool canAssignServerRole( - String actorRole, + Iterable actorRoles, String newRole, Map roles, -) => (roles[actorRole]?.priority ?? 0) >= (roles[newRole]?.priority ?? 0); +) => highestRolePriority(actorRoles, roles) >= (roles[newRole]?.priority ?? 0); diff --git a/server/lib/src/bloc.dart b/server/lib/src/bloc.dart index 62879803..3ddeb772 100644 --- a/server/lib/src/bloc.dart +++ b/server/lib/src/bloc.dart @@ -207,13 +207,13 @@ class WorldBloc extends Bloc final user = server.userManager.getUser(packet.channel); if (user == null || !canProcessClientEvent( - user.role, + user.roles, data, server.configManager.serverRoles, )) { server.log( 'Rejected ${data.runtimeType} from channel ${packet.channel}: ' - '${user == null ? 'unauthenticated' : 'role ${user.role}'}', + '${user == null ? 'unauthenticated' : 'roles ${user.roles.join(', ')}'}', level: LogLevel.warning, ); return; @@ -272,6 +272,22 @@ class WorldBloc extends Bloc case KickServerResponse(): server.kick(packet.channel, process.message); } + if (data is AuthenticateRequest) { + final user = server.userManager.getUser(packet.channel); + if (user != null) { + final validRoles = { + kDefaultServerRole, + ...user.roles.where(server.configManager.serverRoles.containsKey), + }; + if (validRoles.length != user.roles.length || + !validRoles.containsAll(user.roles)) { + await server.userManager.changeRoles( + '#${packet.channel}', + validRoles, + ); + } + } + } if (data is UserJoined || data is AuthenticateRequest) { await server.broadcastServerState(this); } @@ -301,7 +317,7 @@ class WorldBloc extends Bloc case KickPlayerRequest(): if (actor == null || !targetIsInWorld || - !canManageServerRole(actor.role, target.role, roles)) { + !canManageServerRoles(actor.roles, target.roles, roles)) { return true; } server.kick( @@ -311,7 +327,7 @@ class WorldBloc extends Bloc case BanPlayerRequest(): if (actor == null || !targetIsInWorld || - !canManageServerRole(actor.role, target.role, roles)) { + !canManageServerRoles(actor.roles, target.roles, roles)) { return true; } final changed = await server.userManager.changeBan( @@ -327,14 +343,20 @@ class WorldBloc extends Bloc ); } case ServerRoleChangeRequest(): + final requestedRoles = event.effectiveRoles; if (actor == null || !targetIsInWorld || - !roles.containsKey(event.role) || - !canManageServerRole(actor.role, target.role, roles) || - !canAssignServerRole(actor.role, event.role, roles)) { + requestedRoles.any((role) => !roles.containsKey(role)) || + !canManageServerRoles(actor.roles, target.roles, roles) || + requestedRoles.any( + (role) => !canAssignServerRole(actor.roles, role, roles), + )) { return true; } - await server.userManager.changeRole('#${event.player}', event.role); + await server.userManager.changeRoles( + '#${event.player}', + requestedRoles, + ); await server.broadcastServerState(this); case GameRolesChangeRequest(): if (!targetIsInWorld) return true; diff --git a/server/lib/src/config.dart b/server/lib/src/config.dart index 56b912ae..b23a9b30 100644 --- a/server/lib/src/config.dart +++ b/server/lib/src/config.dart @@ -6,6 +6,8 @@ import 'package:setonix_server/setonix_server.dart'; class ConfigManager { SetonixConfig _config = SetonixConfig(); + Map? _runtimeServerRoles; + File? _configFile; final SetonixConfig _envConfig; late SetonixConfig _mergedConfig = _mergeConfig(); SetonixConfig _argsConfig = SetonixConfig(); @@ -26,6 +28,7 @@ class ConfigManager { Future loadConfig({String rootPath = '.'}) async { final file = File(p.join(rootPath, 'config.json')); + _configFile = file; if (await file.exists()) { final content = await file.readAsString(); _config = SetonixConfigMapper.fromJson(content); @@ -75,7 +78,20 @@ class ConfigManager { _mergedConfig.endpointSecret ?? SetonixConfig.defaultEndpointSecret; Map get serverRoles => - _mergedConfig.serverRoles ?? kDefaultServerRoles; + _runtimeServerRoles ?? _mergedConfig.serverRoles ?? kDefaultServerRoles; + + Future setServerRoles(Map roles) async { + final updated = Map.unmodifiable(roles); + _runtimeServerRoles = updated; + _config = _config.copyWith(serverRoles: updated); + _mergedConfig = _mergeConfig(); + final file = _configFile; + if (file != null) { + await file.writeAsString( + JsonEncoder.withIndent(' ').convert(_config.toMap()), + ); + } + } ItemLocation? get gameMode { final data = _mergedConfig.gameMode ?? SetonixConfig.defaultGameMode; diff --git a/server/lib/src/programs/players.dart b/server/lib/src/programs/players.dart index c64d1ca1..20199013 100644 --- a/server/lib/src/programs/players.dart +++ b/server/lib/src/programs/players.dart @@ -17,7 +17,7 @@ class PlayersProgram extends ConsoleProgram { for (final player in players) { final connectionInfo = server.players[player.key]; print( - "${connectionInfo?.address} | #${player.key}) | @${player.value.name} | ${player.value.role} | *${player.value.fingerprint ?? 'No Fingerprint'}", + "${connectionInfo?.address} | #${player.key}) | @${player.value.name} | ${player.value.roles.join(', ')} | *${player.value.fingerprint ?? 'No Fingerprint'}", ); } print("-----"); diff --git a/server/lib/src/programs/role.dart b/server/lib/src/programs/role.dart index 93338901..272c9c40 100644 --- a/server/lib/src/programs/role.dart +++ b/server/lib/src/programs/role.dart @@ -1,4 +1,5 @@ import 'package:consoler/consoler.dart'; +import 'package:setonix_api/setonix_api.dart'; import 'package:setonix_server/src/server.dart'; class RoleProgram extends ConsoleProgram { @@ -7,27 +8,72 @@ class RoleProgram extends ConsoleProgram { RoleProgram(this.server); @override - String getUsage() => ' '; + String getUsage() => + ' | [Roles...]'; @override - String getDescription() => 'Set the authorization role of a user.'; + String getDescription() => 'List, add, remove, or set user roles.'; @override Future run(String label, List args) async { - if (args.length != 2) { + if (args.length < 2) { server.log('Wrong usage, use ${getUsage()}', level: LogLevel.error); return; } - final role = args[1]; - if (!server.configManager.serverRoles.containsKey(role)) { - server.log('Unknown role: ${args[1]}', level: LogLevel.error); + final operations = {'list', 'add', 'remove', 'set'}; + final operation = operations.contains(args.first) ? args.first : 'set'; + final userReference = operation == 'set' && !operations.contains(args.first) + ? args.first + : args[1]; + final requestedRoles = + operation == 'set' && !operations.contains(args.first) + ? args.skip(1).toSet() + : args.skip(2).toSet(); + final user = await server.userManager.getUserByReference(userReference); + if (user == null) { + server.log('User not found: $userReference', level: LogLevel.error); return; } - if (!await server.userManager.changeRole(args[0], role)) { - server.log('User not found: ${args[0]}', level: LogLevel.error); + if (operation == 'list') { + if (args.length != 2) { + server.log('Wrong usage, use ${getUsage()}', level: LogLevel.error); + return; + } + server.log( + '$userReference roles: ${user.roles.join(', ')}', + level: LogLevel.info, + ); return; } + if (requestedRoles.isEmpty || + requestedRoles.any( + (role) => !server.configManager.serverRoles.containsKey(role), + )) { + server.log('Unknown or missing role.', level: LogLevel.error); + return; + } + var roles = Set.from(user.roles); + switch (operation) { + case 'add': + roles.addAll(requestedRoles); + case 'remove': + if (requestedRoles.contains(kDefaultServerRole)) { + server.log( + 'The default role $kDefaultServerRole cannot be removed.', + level: LogLevel.error, + ); + return; + } + roles.removeAll(requestedRoles); + case 'set': + roles = requestedRoles; + } + roles.add(kDefaultServerRole); + if (!await server.userManager.changeRoles(userReference, roles)) return; await server.broadcastAllServerStates(); - server.log('Changed ${args[0]} role to $role', level: LogLevel.info); + server.log( + 'Changed $userReference roles to ${roles.join(', ')}', + level: LogLevel.info, + ); } } diff --git a/server/lib/src/programs/roles.dart b/server/lib/src/programs/roles.dart new file mode 100644 index 00000000..bca62975 --- /dev/null +++ b/server/lib/src/programs/roles.dart @@ -0,0 +1,166 @@ +import 'package:consoler/consoler.dart'; +import 'package:setonix_api/setonix_api.dart'; +import 'package:setonix_server/src/server.dart'; + +class RolesProgram extends ConsoleProgram { + final SetonixServer server; + + RolesProgram(this.server); + + @override + String getUsage() => + ' ...'; + + @override + String getDescription() => + 'List and persistently manage server role definitions.'; + + void _usage() => server.log('''Usage: +roles list +roles show +roles create +roles delete [Replacement] +roles rename +roles priority +roles permission ''', level: LogLevel.info); + + Future _save( + Map roles, { + bool broadcast = true, + }) async { + await server.configManager.setServerRoles(roles); + if (broadcast) await server.broadcastAllServerStates(); + } + + @override + Future run(String label, List args) async { + final action = args.firstOrNull?.toLowerCase() ?? 'list'; + final roles = Map.from( + server.configManager.serverRoles, + ); + switch (action) { + case 'list': + if (args.length > 1) return _usage(); + server.log('${roles.length} role(s):', level: LogLevel.info); + for (final entry in roles.entries) { + server.log( + '${entry.key} | ${entry.value.name} | priority ' + '${entry.value.priority} | ' + '${entry.value.permissions.join(', ')}', + level: LogLevel.info, + ); + } + case 'show': + if (args.length != 2) return _usage(); + final role = roles[args[1]]; + if (role == null) return _unknownRole(args[1]); + server.log( + '${args[1]}\nName: ${role.name}\nPriority: ${role.priority}\n' + 'Permissions: ${role.permissions.join(', ')}', + level: LogLevel.info, + ); + case 'create': + if (args.length < 4) return _usage(); + final id = args[1]; + if (roles.containsKey(id)) { + server.log('Role already exists: $id', level: LogLevel.error); + return; + } + final priority = int.tryParse(args[2]); + if (priority == null) return _invalidPriority(args[2]); + roles[id] = ServerRoleDefinition( + name: args.skip(3).join(' '), + priority: priority, + ); + await _save(roles); + server.log('Created role $id.', level: LogLevel.info); + case 'delete': + if (args.length < 2 || args.length > 3) return _usage(); + final id = args[1]; + if (!roles.containsKey(id)) return _unknownRole(id); + if (id == kDefaultServerRole) { + server.log( + 'The default role $kDefaultServerRole cannot be deleted.', + level: LogLevel.error, + ); + return; + } + final replacement = args.elementAtOrNull(2) ?? kDefaultServerRole; + if (replacement == id || !roles.containsKey(replacement)) { + return _unknownRole(replacement); + } + roles.remove(id); + await _save(roles, broadcast: false); + final changed = await server.userManager.replaceRole(id, replacement); + await server.broadcastAllServerStates(); + server.log( + 'Deleted role $id and changed $changed connected user(s) to ' + '$replacement.', + level: LogLevel.info, + ); + case 'rename': + if (args.length < 3) return _usage(); + final id = args[1]; + final role = roles[id]; + if (role == null) return _unknownRole(id); + roles[id] = role.copyWith(name: args.skip(2).join(' ')); + await _save(roles); + server.log('Renamed role $id.', level: LogLevel.info); + case 'priority': + if (args.length != 3) return _usage(); + final id = args[1]; + final role = roles[id]; + if (role == null) return _unknownRole(id); + final priority = int.tryParse(args[2]); + if (priority == null) return _invalidPriority(args[2]); + roles[id] = role.copyWith(priority: priority); + await _save(roles); + server.log('Changed $id priority to $priority.', level: LogLevel.info); + case 'permission': + if (args.length != 4) return _usage(); + final operation = args[1].toLowerCase(); + final id = args[2]; + final permission = args[3]; + final role = roles[id]; + if (role == null) return _unknownRole(id); + final permissions = Set.from(role.permissions); + switch (operation) { + case 'add': + if (!permissions.add(permission)) { + server.log( + '$id already has permission $permission.', + level: LogLevel.error, + ); + return; + } + case 'remove': + if (!permissions.remove(permission)) { + server.log( + '$id does not have permission $permission.', + level: LogLevel.error, + ); + return; + } + default: + return _usage(); + } + roles[id] = role.copyWith(permissions: permissions); + await _save(roles); + server.log( + '${operation == 'add' ? 'Added' : 'Removed'} $permission ' + '${operation == 'add' ? 'to' : 'from'} $id.', + level: LogLevel.info, + ); + default: + _usage(); + } + } + + void _unknownRole(String role) => + server.log('Unknown role: $role', level: LogLevel.error); + + void _invalidPriority(String priority) => server.log( + 'Priority must be an integer: $priority', + level: LogLevel.error, + ); +} diff --git a/server/lib/src/server.dart b/server/lib/src/server.dart index b29978a6..0876303c 100644 --- a/server/lib/src/server.dart +++ b/server/lib/src/server.dart @@ -16,6 +16,7 @@ import 'package:setonix_server/src/programs/packs.dart'; import 'package:setonix_server/src/programs/players.dart'; import 'package:setonix_server/src/programs/reset.dart'; import 'package:setonix_server/src/programs/role.dart'; +import 'package:setonix_server/src/programs/roles.dart'; import 'package:setonix_server/src/programs/save.dart'; import 'package:setonix_server/src/programs/say.dart'; import 'package:setonix_server/src/programs/scripts.dart'; @@ -107,7 +108,7 @@ final class SetonixServer { getUserWorld(channel)?.state; ServerState buildServerState(Channel viewer, WorldBloc world) { - final viewerRole = userManager.getUser(viewer)?.role ?? ''; + final viewerRoles = userManager.getUser(viewer)?.roles ?? const {}; return ServerState( players: userManager .getUsers() @@ -116,23 +117,23 @@ final class SetonixServer { (entry) => PlayerInfo( id: entry.key, name: entry.value.name, - serverRole: entry.value.role, + serverRoles: entry.value.roles, gameRoles: world.state.getGameRoles(entry.key), registered: entry.value.fingerprint != null, - manageable: canManageServerRole( - viewerRole, - entry.value.role, + manageable: canManageServerRoles( + viewerRoles, + entry.value.roles, configManager.serverRoles, ), ), ) .toList(growable: false), serverRoles: configManager.serverRoles, - permissions: permissionsForRole(viewerRole, configManager.serverRoles), + permissions: permissionsForRoles(viewerRoles, configManager.serverRoles), assignableServerRoles: configManager.serverRoles.keys .where( (role) => canAssignServerRole( - viewerRole, + viewerRoles, role, configManager.serverRoles, ), @@ -354,6 +355,7 @@ final class SetonixServer { 'say': SayProgram(this), 'reset': ResetProgram(this), 'role': RoleProgram(this), + 'roles': RolesProgram(this), 'kick': KickProgram(this), 'ban': BanProgram(this, banned: true), 'unban': BanProgram(this, banned: false), diff --git a/server/lib/src/services/user/file.dart b/server/lib/src/services/user/file.dart index 715c364c..466c73c6 100644 --- a/server/lib/src/services/user/file.dart +++ b/server/lib/src/services/user/file.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:path/path.dart' as p; import 'package:setonix_api/setonix_api.dart'; @@ -8,6 +9,20 @@ import 'package:sqlite3/sqlite3.dart'; final class FileUserService extends UserService { Database? _database; + Set _decodeRoles(Object? value) { + if (value is! String || value.isEmpty) return const {kDefaultServerRole}; + try { + final decoded = jsonDecode(value); + if (decoded is List) { + final roles = decoded.whereType().where((e) => e.isNotEmpty); + if (roles.isNotEmpty) return {...roles, kDefaultServerRole}; + } + } catch (_) { + return {kDefaultServerRole, value}; + } + return const {kDefaultServerRole}; + } + Future setup({String rootPath = '.'}) async { final database = sqlite3.open(p.join(rootPath, 'setonix.db')); // Basic migration system @@ -33,9 +48,7 @@ final class FileUserService extends UserService { fingerprint: row['fingerprint'] as String?, name: row['name'] as String, onWhitelist: row['on_whitelist'] == 1, - role: (row['role'] as String?)?.isNotEmpty == true - ? row['role'] as String - : kDefaultServerRole, + roles: _decodeRoles(row['roles']), banned: row['banned'] == 1, bannedUntil: row['banned_until'] != null ? DateTime.fromMillisecondsSinceEpoch(row['banned_until'] as int) @@ -70,7 +83,7 @@ final class FileUserService extends UserService { String fingerprint, { String? name, bool? onWhitelist, - String? role, + Set? roles, bool? banned, DateTime? bannedUntil, String? banReason, @@ -88,9 +101,9 @@ final class FileUserService extends UserService { updates.add('on_whitelist = ?'); values.add(onWhitelist ? 1 : 0); } - if (role != null) { - updates.add('role = ?'); - values.add(role); + if (roles != null) { + updates.add('roles = ?'); + values.add(jsonEncode(roles.toList(growable: false))); } if (banned != null) { updates.addAll(['banned = ?', 'banned_until = ?', 'ban_reason = ?']); @@ -127,9 +140,9 @@ final class FileUserService extends UserService { insertCols.add('on_whitelist'); insertVals.add(onWhitelist ? 1 : 0); } - if (role != null) { - insertCols.add('role'); - insertVals.add(role); + if (roles != null) { + insertCols.add('roles'); + insertVals.add(jsonEncode(roles.toList(growable: false))); } if (banned != null) { insertCols.addAll(['banned', 'banned_until', 'ban_reason']); @@ -164,6 +177,23 @@ final class FileUserService extends UserService { return true; } + @override + bool replaceRole(String role, String replacement) { + final database = _database; + if (database == null) return false; + final users = database.select('SELECT fingerprint, roles FROM users'); + for (final user in users) { + final roles = _decodeRoles(user['roles']); + if (!roles.remove(role)) continue; + roles.add(replacement); + database.execute('UPDATE users SET roles = ? WHERE fingerprint = ?', [ + jsonEncode(roles.toList(growable: false)), + user['fingerprint'], + ]); + } + return true; + } + @override void close() { _database?.close(); diff --git a/server/lib/src/services/user/migrations.dart b/server/lib/src/services/user/migrations.dart index 8814a3a0..2962cc06 100644 --- a/server/lib/src/services/user/migrations.dart +++ b/server/lib/src/services/user/migrations.dart @@ -15,5 +15,9 @@ CREATE TABLE users ( ALTER TABLE users ADD COLUMN banned INTEGER NOT NULL DEFAULT 0; ALTER TABLE users ADD COLUMN banned_until INTEGER; ALTER TABLE users ADD COLUMN ban_reason TEXT; +''', + 3: ''' +ALTER TABLE users ADD COLUMN roles TEXT; +UPDATE users SET roles = role; ''', }; diff --git a/server/lib/src/services/user/remote.dart b/server/lib/src/services/user/remote.dart index 66b803e3..7a94ab7a 100644 --- a/server/lib/src/services/user/remote.dart +++ b/server/lib/src/services/user/remote.dart @@ -17,6 +17,25 @@ final class RemoteUserService extends UserService { ? {'Authorization': 'Bearer $endpointSecret'} : {}; + SetonixUser _decodeUser(String body) { + final data = jsonDecode(body) as Map; + final legacyRole = data['role']; + if (data['roles'] == null && + legacyRole is String && + legacyRole.isNotEmpty) { + data['roles'] = [kDefaultServerRole, legacyRole]; + } + return SetonixUserMapper.fromMap(data); + } + + String? _legacyRole(Set? roles) { + if (roles == null) return null; + return roles.firstWhere( + (role) => role != kDefaultServerRole, + orElse: () => kDefaultServerRole, + ); + } + @override Future getUser(String fingerprint) async { final response = await http @@ -27,7 +46,7 @@ final class RemoteUserService extends UserService { .timeout(requestTimeout); if (response.statusCode >= 200 && response.statusCode < 300) { if (response.body.isEmpty) return null; // No user found - return SetonixUserMapper.fromJson(response.body); + return _decodeUser(response.body); } throw KickMessage.fromString(response.body); } @@ -41,7 +60,7 @@ final class RemoteUserService extends UserService { ) .timeout(requestTimeout); if (response.statusCode == 200) { - return SetonixUserMapper.fromJson(response.body); + return _decodeUser(response.body); } return null; } @@ -51,7 +70,7 @@ final class RemoteUserService extends UserService { String fingerprint, { String? name, bool? onWhitelist, - String? role, + Set? roles, bool? banned, DateTime? bannedUntil, String? banReason, @@ -61,7 +80,8 @@ final class RemoteUserService extends UserService { final body = jsonEncode({ 'name': name, 'onWhitelist': onWhitelist, - 'role': role, + 'role': _legacyRole(roles), + 'roles': roles?.toList(growable: false), 'banned': banned, 'bannedUntil': bannedUntil?.millisecondsSinceEpoch, 'banReason': banReason, @@ -83,7 +103,8 @@ final class RemoteUserService extends UserService { 'fingerprint': fingerprint, 'name': name, 'onWhitelist': onWhitelist, - 'role': role, + 'role': _legacyRole(roles), + 'roles': roles?.toList(growable: false), 'banned': banned, 'bannedUntil': bannedUntil?.millisecondsSinceEpoch, 'banReason': banReason, diff --git a/server/test/authorization_test.dart b/server/test/authorization_test.dart index 613e7899..0d557887 100644 --- a/server/test/authorization_test.dart +++ b/server/test/authorization_test.dart @@ -8,12 +8,12 @@ void main() { group('server authorization', () { test('spectators can observe and chat but cannot mutate the world', () { expect( - canProcessClientEvent('spectator', MessageRequest('hello'), roles), + canProcessClientEvent({'spectator'}, MessageRequest('hello'), roles), isTrue, ); expect( canProcessClientEvent( - 'spectator', + {'spectator'}, CellRollRequest(GlobalVectorDefinition('', 0, 0)), roles, ), @@ -23,15 +23,15 @@ void main() { test('players can play but cannot change world configuration', () { expect( - canProcessClientEvent('player', TeamJoinRequest('players'), roles), + canProcessClientEvent({'player'}, TeamJoinRequest('players'), roles), isTrue, ); expect( - canProcessClientEvent('player', PacksChangeRequest(const []), roles), + canProcessClientEvent({'player'}, PacksChangeRequest(const []), roles), isFalse, ); expect( - canProcessClientEvent('player', ModeChangeRequest(null), roles), + canProcessClientEvent({'player'}, ModeChangeRequest(null), roles), isFalse, ); }); @@ -39,16 +39,16 @@ void main() { test('moderators and owners can manage world configuration', () { final event = PacksChangeRequest(const []); - expect(canProcessClientEvent('moderator', event, roles), isTrue); - expect(canProcessClientEvent('owner', event, roles), isTrue); + expect(canProcessClientEvent({'moderator'}, event, roles), isTrue); + expect(canProcessClientEvent({'owner'}, event, roles), isTrue); }); test('new unclassified client events default to play permission', () { final event = ToolbarActionRequest('test'); expect(requiredPermission(event), ServerPermission.play); - expect(canProcessClientEvent('spectator', event, roles), isFalse); - expect(canProcessClientEvent('player', event, roles), isTrue); + expect(canProcessClientEvent({'spectator'}, event, roles), isFalse); + expect(canProcessClientEvent({'player'}, event, roles), isTrue); }); test('custom server roles are capability-driven', () { @@ -66,14 +66,14 @@ void main() { expect( canProcessClientEvent( - 'dealer', + {'dealer'}, PacksChangeRequest(const []), customRoles, ), isTrue, ); expect( - canProcessClientEvent('dealer', KickPlayerRequest(2), customRoles), + canProcessClientEvent({'dealer'}, KickPlayerRequest(2), customRoles), isFalse, ); expect(roleAllowsPermission('dealer', deal, customRoles), isTrue); @@ -84,16 +84,42 @@ void main() { }); test('role hierarchy prevents managing peers and higher roles', () { - expect(canManageServerRole('moderator', 'player', roles), isTrue); - expect(canManageServerRole('moderator', 'moderator', roles), isFalse); - expect(canManageServerRole('moderator', 'owner', roles), isFalse); - expect(canManageServerRole('owner', 'moderator', roles), isTrue); + expect( + canManageServerRoles({'player', 'moderator'}, {'player'}, roles), + isTrue, + ); + expect( + canManageServerRoles({'moderator'}, {'moderator'}, roles), + isFalse, + ); + expect( + canManageServerRoles({'moderator'}, {'player', 'owner'}, roles), + isFalse, + ); + expect( + canManageServerRoles({'player', 'owner'}, {'moderator'}, roles), + isTrue, + ); }); test('roles can only assign roles at or below their priority', () { - expect(canAssignServerRole('moderator', 'moderator', roles), isTrue); - expect(canAssignServerRole('moderator', 'owner', roles), isFalse); - expect(canAssignServerRole('owner', 'owner', roles), isTrue); + expect( + canAssignServerRole({'player', 'moderator'}, 'moderator', roles), + isTrue, + ); + expect(canAssignServerRole({'moderator'}, 'owner', roles), isFalse); + expect(canAssignServerRole({'player', 'owner'}, 'owner', roles), isTrue); + }); + + test('multiple roles union their permissions', () { + expect( + permissionsForRoles({'spectator', 'moderator'}, roles), + containsAll({ + ServerPermission.play, + ServerPermission.manageWorld, + ServerPermission.kickPlayers, + }), + ); }); test('custom roles survive server configuration serialization', () { diff --git a/server/test/config_role_test.dart b/server/test/config_role_test.dart new file mode 100644 index 00000000..19132b17 --- /dev/null +++ b/server/test/config_role_test.dart @@ -0,0 +1,39 @@ +import 'dart:io'; + +import 'package:setonix_api/setonix_api.dart'; +import 'package:setonix_server/src/config.dart'; +import 'package:test/test.dart'; + +void main() { + test('role definition changes persist to config.json', () async { + final directory = await Directory.systemTemp.createTemp( + 'setonix-role-config-', + ); + addTearDown(() => directory.delete(recursive: true)); + + final manager = ConfigManager( + envConfig: const SetonixConfig(), + argsConfig: const SetonixConfig(), + ); + await manager.loadConfig(rootPath: directory.path); + await manager.setServerRoles({ + ...manager.serverRoles, + 'blackjack:dealer': const ServerRoleDefinition( + name: 'Dealer', + priority: 25, + permissions: {'blackjack:deal'}, + ), + }); + + final reloaded = ConfigManager( + envConfig: const SetonixConfig(), + argsConfig: const SetonixConfig(), + ); + await reloaded.loadConfig(rootPath: directory.path); + + expect(reloaded.serverRoles['blackjack:dealer']?.priority, 25); + expect(reloaded.serverRoles['blackjack:dealer']?.permissions, { + 'blackjack:deal', + }); + }); +} diff --git a/server/test/user_role_test.dart b/server/test/user_role_test.dart index bfd73c64..056a1090 100644 --- a/server/test/user_role_test.dart +++ b/server/test/user_role_test.dart @@ -2,6 +2,8 @@ import 'dart:io'; import 'package:setonix_api/setonix_api.dart'; import 'package:setonix_server/src/services/user/file.dart'; +import 'package:setonix_server/src/services/user/migrations.dart'; +import 'package:sqlite3/sqlite3.dart'; import 'package:test/test.dart'; void main() { @@ -20,27 +22,72 @@ void main() { service.updateUser( 'fingerprint', name: 'player', - role: 'spectator', + roles: {'player', 'spectator'}, createIfNotExists: true, ), isTrue, ); - expect(service.getUser('fingerprint')?.role, 'spectator'); + expect(service.getUser('fingerprint')?.roles, {'player', 'spectator'}); - expect(service.updateUser('fingerprint', role: 'moderator'), isTrue); - expect(service.getUser('fingerprint')?.role, 'moderator'); + expect( + service.updateUser( + 'fingerprint', + roles: {'player', 'moderator', 'blackjack:dealer'}, + ), + isTrue, + ); + expect(service.getUser('fingerprint')?.roles, { + 'player', + 'moderator', + 'blackjack:dealer', + }); - expect(service.updateUser('fingerprint', role: ''), isTrue); - expect(service.getUser('fingerprint')?.role, kDefaultServerRole); + expect(service.replaceRole('moderator', 'spectator'), isTrue); + expect(service.getUser('fingerprint')?.roles, { + 'player', + 'spectator', + 'blackjack:dealer', + }); + + final manager = UserManager(service: service); + await manager.addUser(1, 'fingerprint'); + expect(await manager.addRole('#1', 'moderator'), isTrue); + expect(await manager.removeRole('#1', 'spectator'), isTrue); + expect(manager.getUser(1)?.roles, { + 'player', + 'moderator', + 'blackjack:dealer', + }); }); test('custom role identifiers are preserved', () { final user = SetonixUserMapper.fromMap({ 'name': 'dealer', - 'role': 'blackjack-dealer', + 'roles': ['player', 'blackjack:dealer'], }); - expect(user.role, 'blackjack-dealer'); + expect(user.roles, {'player', 'blackjack:dealer'}); + }); + + test('legacy single roles migrate into composable role sets', () async { + final directory = await Directory.systemTemp.createTemp( + 'setonix-user-role-migration-', + ); + addTearDown(() => directory.delete(recursive: true)); + final database = sqlite3.open('${directory.path}/setonix.db'); + database.execute(migrations[1]!); + database.execute(migrations[2]!); + database.execute( + "INSERT INTO users (fingerprint, name, role) VALUES ('legacy', 'Legacy', 'moderator')", + ); + database.execute('PRAGMA user_version = 2'); + database.close(); + + final service = FileUserService(); + addTearDown(service.close); + await service.setup(rootPath: directory.path); + + expect(service.getUser('legacy')?.roles, {'player', 'moderator'}); }); test('ban state supports permanent and expiring bans', () { From e05b4a8a99f9512b66df3e8dc8c452bcb370676b Mon Sep 17 00:00:00 2001 From: CodeDoctorDE Date: Wed, 12 Aug 2026 20:04:07 +0200 Subject: [PATCH 3/8] Fix role-authorized world management --- api/lib/src/event/process/client.dart | 17 ++++-- server/lib/src/bloc.dart | 3 ++ server/lib/src/config.dart | 11 +++- server/lib/src/server.dart | 16 +++--- server/test/authorization_test.dart | 77 ++++++++++++++++----------- server/test/config_role_test.dart | 19 +++++++ server/test/user_role_test.dart | 16 +----- 7 files changed, 99 insertions(+), 60 deletions(-) diff --git a/api/lib/src/event/process/client.dart b/api/lib/src/event/process/client.dart index 098827db..f1c61af0 100644 --- a/api/lib/src/event/process/client.dart +++ b/api/lib/src/event/process/client.dart @@ -19,6 +19,7 @@ bool isValidClientEvent( WorldState state, { required AssetManager assetManager, ChallengeManager? challengeManager, + bool allowManagementRequests = false, }) => switch (event) { TeamJoinRequest() => _isReasonableIdentifier(event.team) && @@ -82,7 +83,7 @@ bool isValidClientEvent( ), TeamRemoved() => state.info.teams.containsKey(event.team), PacksChangeRequest() => - channel == kAuthorityChannel && + (channel == kAuthorityChannel || allowManagementRequests) && event.packs.length <= _maxPacksPerRequest && event.packs.every(_isReasonableIdentifier), MessageRequest() => @@ -103,7 +104,8 @@ bool isValidClientEvent( .length - 1, ), - ModeChangeRequest() => channel == kAuthorityChannel, + ModeChangeRequest() => + channel == kAuthorityChannel || allowManagementRequests, DialogCloseRequest() => _isReasonableIdentifier(event.id), ImagesRequest() => event.ids.length <= _maxImagesPerRequest && @@ -200,6 +202,7 @@ Future processClientEvent( WorldState state, { required AssetManager assetManager, bool allowServerEvents = false, + bool allowManagementRequests = false, ChallengeManager? challengeManager, UserManager? userManager, }) async { @@ -226,7 +229,13 @@ Future processClientEvent( await userManager?.addUser(channel); return UpdateServerResponse.builder(buildInitialize(), channel); } - if (!isValidClientEvent(event, channel, state, assetManager: assetManager)) { + if (!isValidClientEvent( + event, + channel, + state, + assetManager: assetManager, + allowManagementRequests: allowManagementRequests, + )) { return null; } switch (event) { @@ -414,7 +423,7 @@ Future processClientEvent( final mode = location == null ? null : assetManager.getModeItem(location); return UpdateServerResponse.builder( WorldInitialized.fromMode(mode, state), - channel, + kAnyChannel, ); case AuthenticateRequest(): final challenge = challengeManager?.getChallenge(channel); diff --git a/server/lib/src/bloc.dart b/server/lib/src/bloc.dart index 3ddeb772..a563bf19 100644 --- a/server/lib/src/bloc.dart +++ b/server/lib/src/bloc.dart @@ -228,6 +228,9 @@ class WorldBloc extends Bloc state, assetManager: assetManager, allowServerEvents: packet.isServer, + // The dedicated server has already applied role authorization above. + // Peer-hosted games retain the shared API's authority-only default. + allowManagementRequests: true, challengeManager: server.challengeManager, userManager: server.userManager, ); diff --git a/server/lib/src/config.dart b/server/lib/src/config.dart index b23a9b30..56af7335 100644 --- a/server/lib/src/config.dart +++ b/server/lib/src/config.dart @@ -77,8 +77,15 @@ class ConfigManager { String get endpointSecret => _mergedConfig.endpointSecret ?? SetonixConfig.defaultEndpointSecret; - Map get serverRoles => - _runtimeServerRoles ?? _mergedConfig.serverRoles ?? kDefaultServerRoles; + Map get serverRoles { + final configured = + _runtimeServerRoles ?? _mergedConfig.serverRoles ?? kDefaultServerRoles; + if (configured.containsKey(kDefaultServerRole)) return configured; + return Map.unmodifiable({ + kDefaultServerRole: kDefaultServerRoles[kDefaultServerRole]!, + ...configured, + }); + } Future setServerRoles(Map roles) async { final updated = Map.unmodifiable(roles); diff --git a/server/lib/src/server.dart b/server/lib/src/server.dart index 0876303c..4d83bba0 100644 --- a/server/lib/src/server.dart +++ b/server/lib/src/server.dart @@ -144,13 +144,15 @@ final class SetonixServer { Future broadcastServerState(WorldBloc world) async { await Future.wait( - world.players.map( - (channel) => sendEvent( - ServerStateUpdated(buildServerState(channel, world)), - target: channel, - worldName: world.worldName, - ), - ), + world.players + .where((channel) => userManager.getUser(channel) != null) + .map( + (channel) => sendEvent( + ServerStateUpdated(buildServerState(channel, world)), + target: channel, + worldName: world.worldName, + ), + ), ); } diff --git a/server/test/authorization_test.dart b/server/test/authorization_test.dart index 0d557887..a17940a1 100644 --- a/server/test/authorization_test.dart +++ b/server/test/authorization_test.dart @@ -1,3 +1,4 @@ +import 'package:networker/networker.dart'; import 'package:setonix_api/setonix_api.dart'; import 'package:setonix_server/src/authorization.dart'; import 'package:test/test.dart'; @@ -43,6 +44,39 @@ void main() { expect(canProcessClientEvent({'owner'}, event, roles), isTrue); }); + test('authorized remote users can broadcast mode changes', () async { + final state = WorldState(data: SetonixData.empty()); + final assets = _EmptyAssetManager(); + final event = ModeChangeRequest.plain(); + + expect( + isValidClientEvent(event, 2, state, assetManager: assets), + isFalse, + ); + expect( + isValidClientEvent( + event, + 2, + state, + assetManager: assets, + allowManagementRequests: true, + ), + isTrue, + ); + + final response = await processClientEvent( + event, + 2, + state, + assetManager: assets, + allowManagementRequests: true, + ); + + expect(response, isA()); + expect((response as UpdateServerResponse).main?.channel, kAnyChannel); + expect(response.main?.data, isA()); + }); + test('new unclassified client events default to play permission', () { final event = ToolbarActionRequest('test'); @@ -121,37 +155,16 @@ void main() { }), ); }); - - test('custom roles survive server configuration serialization', () { - const config = SetonixConfig( - serverRoles: { - 'blackjack:dealer': ServerRoleDefinition( - name: 'Blackjack dealer', - priority: 25, - permissions: { - ServerPermission.play, - ServerPermission.manageWorld, - 'blackjack:deal', - }, - ), - }, - ); - - final decoded = SetonixConfigMapper.fromMap(config.toMap()); - final dealer = decoded.serverRoles?['blackjack:dealer']; - - expect(dealer?.name, 'Blackjack dealer'); - expect(dealer?.priority, 25); - expect(dealer?.permissions, { - ServerPermission.play, - ServerPermission.manageWorld, - 'blackjack:deal', - }); - expect(config.toMap()['serverRoles']['blackjack:dealer']['permissions'], [ - 'play', - 'manageWorld', - 'blackjack:deal', - ]); - }); }); } + +final class _EmptyAssetManager extends AssetManager { + @override + Iterable> get packs => const []; + + @override + SetonixData? getPack(String key) => null; + + @override + bool hasPack(String key) => false; +} diff --git a/server/test/config_role_test.dart b/server/test/config_role_test.dart index 19132b17..b79be14f 100644 --- a/server/test/config_role_test.dart +++ b/server/test/config_role_test.dart @@ -36,4 +36,23 @@ void main() { 'blackjack:deal', }); }); + + test('partial custom role configuration retains the default player role', () { + final manager = ConfigManager( + envConfig: const SetonixConfig( + serverRoles: { + 'blackjack:dealer': ServerRoleDefinition( + name: 'Dealer', + permissions: {'blackjack:deal'}, + ), + }, + ), + argsConfig: const SetonixConfig(), + ); + + expect( + manager.serverRoles[kDefaultServerRole]?.permissions, + contains(ServerPermission.play), + ); + }); } diff --git a/server/test/user_role_test.dart b/server/test/user_role_test.dart index 056a1090..b1d9eb81 100644 --- a/server/test/user_role_test.dart +++ b/server/test/user_role_test.dart @@ -60,15 +60,6 @@ void main() { }); }); - test('custom role identifiers are preserved', () { - final user = SetonixUserMapper.fromMap({ - 'name': 'dealer', - 'roles': ['player', 'blackjack:dealer'], - }); - - expect(user.roles, {'player', 'blackjack:dealer'}); - }); - test('legacy single roles migrate into composable role sets', () async { final directory = await Directory.systemTemp.createTemp( 'setonix-user-role-migration-', @@ -91,11 +82,7 @@ void main() { }); test('ban state supports permanent and expiring bans', () { - const permanent = SetonixUser( - name: 'permanent', - banned: true, - banReason: 'abuse', - ); + const permanent = SetonixUser(name: 'permanent', banned: true); final expired = SetonixUser( name: 'expired', banned: true, @@ -103,7 +90,6 @@ void main() { ); expect(permanent.isBanned, isTrue); - expect(permanent.banReason, 'abuse'); expect(expired.isBanned, isFalse); }); } From 393a2d58e53d3437e7f46615b972053016e7a005 Mon Sep 17 00:00:00 2001 From: CodeDoctorDE Date: Fri, 14 Aug 2026 18:27:46 +0200 Subject: [PATCH 4/8] Fix ci --- .github/workflows/dart.yml | 2 +- app/analysis_options.yaml | 6 + app/lib/api/open.dart | 3 +- app/lib/bloc/world/bloc.dart | 4 +- app/lib/pages/editor/textures.dart | 2 +- .../Flutter/GeneratedPluginRegistrant.swift | 2 +- app/pubspec.lock | 116 ++++++++++++------ plugin/rust/Cargo.lock | 50 ++++---- server/pubspec.lock | 4 +- 9 files changed, 117 insertions(+), 72 deletions(-) diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 5e696cf3..e1edcd96 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -49,7 +49,7 @@ jobs: - name: Build flutter_rust_bridge bindings if: matrix.projects == 'plugin' run: | - cargo install flutter_rust_bridge_codegen@2.12.0 --locked + cargo install flutter_rust_bridge_codegen@2.13.0-beta.6 --locked flutter_rust_bridge_codegen generate - name: Run build_runner if: matrix.projects == 'api' || matrix.projects == 'app' diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml index 540329a6..c7b86154 100644 --- a/app/analysis_options.yaml +++ b/app/analysis_options.yaml @@ -16,6 +16,12 @@ analyzer: - "build/**" - "lib/src/rust/*.dart" - "lib/src/rust/**/*.dart" + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** errors: invalid_annotation_target: ignore linter: diff --git a/app/lib/api/open.dart b/app/lib/api/open.dart index af89e72a..f91198b4 100644 --- a/app/lib/api/open.dart +++ b/app/lib/api/open.dart @@ -68,8 +68,7 @@ Future openHelp(List pageLocation, [String? fragment]) { Future openFile() async { final result = await FilePicker.pickFiles(allowedExtensions: ['stnx']); - if (result == null) return null; - final file = result.files.firstOrNull; + final file = result.firstOrNull; if (file == null) return null; final bytes = await file.readAsBytes(); final data = SetonixFile(bytes); diff --git a/app/lib/bloc/world/bloc.dart b/app/lib/bloc/world/bloc.dart index 1a086faa..6d43fd2b 100644 --- a/app/lib/bloc/world/bloc.dart +++ b/app/lib/bloc/world/bloc.dart @@ -130,7 +130,7 @@ class WorldBloc extends Bloc { processed.responses.forEach(process); if (newWorld == null) return; emit(state.copyWith(world: newWorld)); - return save(); + return await save(); } on FatalServerEventError catch (e) { state.multiplayer.raiseError(e); } @@ -204,7 +204,7 @@ class WorldBloc extends Bloc { )); final name = state.world.name; if (name == null) return; - return state.fileSystem.worldSystem.updateFile(name, data); + return await state.fileSystem.worldSystem.updateFile(name, data); } finally { _isSaving = false; } diff --git a/app/lib/pages/editor/textures.dart b/app/lib/pages/editor/textures.dart index 7736202a..b9e50cae 100644 --- a/app/lib/pages/editor/textures.dart +++ b/app/lib/pages/editor/textures.dart @@ -37,7 +37,7 @@ class TexturesEditorPage extends StatelessWidget { onPressed: () async { final data = await FilePicker.pickFiles( type: FileType.image, - ).then((result) => result?.files.firstOrNull); + ).then((result) => result.firstOrNull); if (data == null) return; final bytes = await data.readAsBytes(); if (!context.mounted) return; diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift index b3afc59c..c78cbd08 100644 --- a/app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,7 +8,7 @@ import Foundation import cryptography_flutter_plus import device_info_plus import dynamic_color -import file_picker +import file_picker_darwin import flutter_secure_storage_darwin import lw_sysapi import network_info_plus diff --git a/app/pubspec.lock b/app/pubspec.lock index 80c997ba..0fce361c 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -5,18 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: cd6add6f846f35fb79f3c315296703c1a24f3cfd7f4739d91a74961c1c7e9f1b + sha256: "1b0e6a07425a3e460666e88bf1c949ccc7bb0116ad562ce94a1eca60fe820725" url: "https://pub.dev" source: hosted - version: "100.0.0" + version: "103.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "6ba98576948803398b69e3a444df24eacdbe12ed699c7014e120ea38552debbf" + sha256: "61c04d0c1bfed555c681ea079519933f071a5a026578ff73c4ff0df2d3462e5e" url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "13.3.0" + android_file_picker: + dependency: transitive + description: + name: android_file_picker + sha256: "24ccc79cdc9612614703dda2d6fd6c67e8b7b16529b54ab983578488ef2d721c" + url: "https://pub.dev" + source: hosted + version: "1.0.0" ansicolor: dependency: transitive description: @@ -85,10 +93,10 @@ packages: dependency: transitive description: name: build - sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" + sha256: b94f5da9ed3d081fd4ecc426c260998b5f4f4eb2f6d89b7e5472edef0b2b2a1b url: "https://pub.dev" source: hosted - version: "4.0.7" + version: "4.0.10" build_cli_annotations: dependency: transitive description: @@ -117,10 +125,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" + sha256: "90363d438bd9f84a4d5bbb83352251f57d2d9d771bc95a44e6a33fe25fa52774" url: "https://pub.dev" source: hosted - version: "2.15.1" + version: "2.16.0" built_collection: dependency: transitive description: @@ -221,10 +229,10 @@ packages: dependency: "direct main" description: name: cryptography_flutter_plus - sha256: d880a6d98b8a1f46cdde6710c219a324cba97fcf8b1883467920ba6fc183fc5d + sha256: "18373ffd8ff5a7b47db10d70522a6592beaff0eb2e820b2e446f20e9fb982060" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.1.0" cryptography_plus: dependency: "direct main" description: @@ -262,10 +270,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "59d53ef8eaed9d288ed9767618e2b31c4fa0383a127db59d5eb2e737a7638a60" + sha256: "3f88fc9c96c568d631356507355a2d1e983ee000c9ac008bdcb39b5bf53ce777" url: "https://pub.dev" source: hosted - version: "3.1.9" + version: "3.1.12" dbus: dependency: transitive description: @@ -334,10 +342,42 @@ packages: dependency: "direct main" description: name: file_picker - sha256: fdc6a37f715d19f35b131decf1ce39242eeed5ddae18c0818c3eccb731ab76be + sha256: afbaa8015d9efabd224f41084ed9fdeddfa65389ebd7cd3a9eb1476aca66b46d + url: "https://pub.dev" + source: hosted + version: "12.0.0" + file_picker_darwin: + dependency: transitive + description: + name: file_picker_darwin + sha256: "27be6ab5d48771f036b273c4424d83919226d7ceda04c7389cf8d632d9332303" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + file_picker_linux: + dependency: transitive + description: + name: file_picker_linux + sha256: "5f8e1de3d032b6d0b2e840d30481abe41ead3ab28c9753a0187f3465d6797bf1" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + file_picker_platform_interface: + dependency: transitive + description: + name: file_picker_platform_interface + sha256: bfa1bfc65e92d521063ebebe6ffea4e7b0c0be87bbae52d9e69aab08cd2f94f4 url: "https://pub.dev" source: hosted - version: "12.0.0-beta.7" + version: "3.0.0" + file_picker_web: + dependency: transitive + description: + name: file_picker_web + sha256: "3c0d736648b92d488b4ce8de4c88a35998c98287eebd50e9cb3a60bc8a953d96" + url: "https://pub.dev" + source: hosted + version: "3.0.0" fixnum: dependency: transitive description: @@ -420,14 +460,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.5" - flutter_plugin_android_lifecycle: - dependency: transitive - description: - name: flutter_plugin_android_lifecycle - sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" - url: "https://pub.dev" - source: hosted - version: "2.0.35" flutter_rust_bridge: dependency: transitive description: @@ -546,10 +578,10 @@ packages: dependency: "direct main" description: name: hooks - sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + sha256: "03a564c3704524ee0f7fc56fc621e8796cc2eb8c24d1f1a33b34979815b285c4" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.1.0" http: dependency: "direct main" description: @@ -594,10 +626,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.20.3" io: dependency: transitive description: @@ -725,10 +757,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -750,10 +782,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" mime: dependency: transitive description: @@ -857,10 +889,10 @@ packages: dependency: transitive description: name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "3.0.0" package_info_plus: dependency: "direct main" description: @@ -1026,10 +1058,10 @@ packages: dependency: transitive description: name: record_use - sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + sha256: "3b4ab682aff40175afca6ff090cc5aa7ce9dafe8dfbae1537a6c678e6678baee" url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "1.1.0" rxdart: dependency: "direct main" description: @@ -1278,10 +1310,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" toml: dependency: transitive description: @@ -1406,10 +1438,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" visibility_detector: dependency: transitive description: @@ -1482,6 +1514,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.2" + windows_file_picker: + dependency: transitive + description: + name: windows_file_picker + sha256: bb25e1b0098c316cc5bcfa04e858f36644c68598884dcd254fbedbbd62f68406 + url: "https://pub.dev" + source: hosted + version: "1.0.0" xdg_directories: dependency: transitive description: diff --git a/plugin/rust/Cargo.lock b/plugin/rust/Cargo.lock index 79231860..82fa34a6 100644 --- a/plugin/rust/Cargo.lock +++ b/plugin/rust/Cargo.lock @@ -138,9 +138,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "cc" -version = "1.4.2" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -246,9 +246,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "flutter_rust_bridge" @@ -294,9 +294,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -309,9 +309,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -319,15 +319,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -336,38 +336,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -637,9 +637,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "portable-atomic" diff --git a/server/pubspec.lock b/server/pubspec.lock index d0e0aeca..85f587cd 100644 --- a/server/pubspec.lock +++ b/server/pubspec.lock @@ -263,10 +263,10 @@ packages: dependency: transitive description: name: flutter_rust_bridge - sha256: e87d6b9ee934dcd24a128ccb2bd91905d2d5fe5c06245d6a8f5477d4907a437a + sha256: "7e8f565714dcb06197d29c74a559ee826c4eab4d586d7ccf9826aa5dc9478015" url: "https://pub.dev" source: hosted - version: "2.12.0" + version: "2.13.0-beta.6" frontend_server_client: dependency: transitive description: From 2fbce9630780a0246a0ba98c1ed941dcb583af79 Mon Sep 17 00:00:00 2001 From: CodeDoctorDE Date: Fri, 14 Aug 2026 18:43:56 +0200 Subject: [PATCH 5/8] Remove legacy, fix checks --- api/lib/src/event/client.dart | 9 +--- api/lib/src/event/event.mapper.dart | 14 +----- api/lib/src/event/process/client.dart | 23 +++++++++ api/test/roles_test.dart | 16 ++---- app/pubspec.lock | 4 +- app/pubspec.yaml | 2 +- server/lib/src/bloc.dart | 14 ++++++ server/lib/src/services/user/remote.dart | 25 +--------- server/test/authorization_test.dart | 63 ++++++++++++++++++++++++ 9 files changed, 113 insertions(+), 57 deletions(-) diff --git a/api/lib/src/event/client.dart b/api/lib/src/event/client.dart index a8e81653..ba643038 100644 --- a/api/lib/src/event/client.dart +++ b/api/lib/src/event/client.dart @@ -224,15 +224,10 @@ final class ServerRoleChangeRequest extends ClientWorldEvent with ServerRoleChangeRequestMappable { final Channel player; final Set roles; - final String? role; - ServerRoleChangeRequest(this.player, {this.roles = const {}, this.role}); + ServerRoleChangeRequest(this.player, {this.roles = const {}}); - Set get effectiveRoles => { - kDefaultServerRole, - ...roles, - if (role?.isNotEmpty == true) role!, - }; + Set get effectiveRoles => {kDefaultServerRole, ...roles}; } @MappableClass() diff --git a/api/lib/src/event/event.mapper.dart b/api/lib/src/event/event.mapper.dart index 3a3d4342..36be556e 100644 --- a/api/lib/src/event/event.mapper.dart +++ b/api/lib/src/event/event.mapper.dart @@ -5129,18 +5129,11 @@ class ServerRoleChangeRequestMapper opt: true, def: const {}, ); - static String? _$role(ServerRoleChangeRequest v) => v.role; - static const Field _f$role = Field( - 'role', - _$role, - opt: true, - ); @override final MappableFields fields = const { #player: _f$player, #roles: _f$roles, - #role: _f$role, }; @override @@ -5155,7 +5148,6 @@ class ServerRoleChangeRequestMapper return ServerRoleChangeRequest( data.dec(_f$player), roles: data.dec(_f$roles), - role: data.dec(_f$role), ); } @@ -5233,7 +5225,7 @@ abstract class ServerRoleChangeRequestCopyWith< > implements ClientWorldEventCopyWith<$R, $In, $Out> { @override - $R call({int? player, Set? roles, String? role}); + $R call({int? player, Set? roles}); ServerRoleChangeRequestCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>( Then<$Out2, $R2> t, ); @@ -5250,18 +5242,16 @@ class _ServerRoleChangeRequestCopyWithImpl<$R, $Out> late final ClassMapperBase $mapper = ServerRoleChangeRequestMapper.ensureInitialized(); @override - $R call({int? player, Set? roles, Object? role = $none}) => $apply( + $R call({int? player, Set? roles}) => $apply( FieldCopyWithData({ if (player != null) #player: player, if (roles != null) #roles: roles, - if (role != $none) #role: role, }), ); @override ServerRoleChangeRequest $make(CopyWithData data) => ServerRoleChangeRequest( data.get(#player, or: $value.player), roles: data.get(#roles, or: $value.roles), - role: data.get(#role, or: $value.role), ); @override diff --git a/api/lib/src/event/process/client.dart b/api/lib/src/event/process/client.dart index f1c61af0..88b3ccc4 100644 --- a/api/lib/src/event/process/client.dart +++ b/api/lib/src/event/process/client.dart @@ -9,10 +9,18 @@ const _maxIdentifierLength = 256; const _maxImagesPerRequest = 100; const _maxPacksPerRequest = 128; const _maxBoardsPerRequest = 256; +const _maxRolesPerRequest = 32; +const _maxBanDuration = Duration(days: 3650); bool _isReasonableIdentifier(String value) => value.isNotEmpty && value.length <= _maxIdentifierLength; +bool _isPlausibleBanExpiry(DateTime? expiresAt) { + if (expiresAt == null) return true; + final now = DateTime.now(); + return expiresAt.isAfter(now) && !expiresAt.isAfter(now.add(_maxBanDuration)); +} + bool isValidClientEvent( WorldEvent event, Channel channel, @@ -88,6 +96,21 @@ bool isValidClientEvent( event.packs.every(_isReasonableIdentifier), MessageRequest() => event.message.isNotEmpty && event.message.length <= _maxTextLength, + KickPlayerRequest() => + (channel == kAuthorityChannel || allowManagementRequests) && + (event.reason?.length ?? 0) <= _maxTextLength, + BanPlayerRequest() => + (channel == kAuthorityChannel || allowManagementRequests) && + (event.reason?.length ?? 0) <= _maxTextLength && + _isPlausibleBanExpiry(event.expiresAt), + ServerRoleChangeRequest() => + (channel == kAuthorityChannel || allowManagementRequests) && + event.roles.length <= _maxRolesPerRequest && + event.roles.every(_isReasonableIdentifier), + GameRolesChangeRequest() => + (channel == kAuthorityChannel || allowManagementRequests) && + event.roles.length <= _maxRolesPerRequest && + event.roles.every(_isReasonableIdentifier), BoardsSpawnRequest() => _isReasonableIdentifier(event.table) && event.assets.length <= _maxBoardsPerRequest && diff --git a/api/test/roles_test.dart b/api/test/roles_test.dart index aa36fa34..528b6c24 100644 --- a/api/test/roles_test.dart +++ b/api/test/roles_test.dart @@ -13,17 +13,9 @@ void main() { expect(decoded.roles, {'player', 'moderator', 'blackjack:dealer'}); }); - test( - 'role change requests retain player and accept legacy role payloads', - () { - final current = ServerRoleChangeRequest(2, roles: {'moderator'}); - final legacy = ServerRoleChangeRequestMapper.fromMap({ - 'player': 2, - 'role': 'moderator', - }); + test('role change requests retain the player role', () { + final request = ServerRoleChangeRequest(2, roles: {'moderator'}); - expect(current.effectiveRoles, {'player', 'moderator'}); - expect(legacy.effectiveRoles, {'player', 'moderator'}); - }, - ); + expect(request.effectiveRoles, {'player', 'moderator'}); + }); } diff --git a/app/pubspec.lock b/app/pubspec.lock index 0fce361c..d163450e 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -732,8 +732,8 @@ packages: dependency: "direct main" description: path: "packages/lw_sysapi" - ref: d7d00d81291d6a2716da40b9f1d004ac4973771d - resolved-ref: d7d00d81291d6a2716da40b9f1d004ac4973771d + ref: "4504590d871958c48a93969461193691ea88cb18" + resolved-ref: "4504590d871958c48a93969461193691ea88cb18" url: "https://github.com/LinwoodDev/dart_pkgs.git" source: git version: "0.0.1" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index b3bce995..6beccda5 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -85,7 +85,7 @@ dependencies: lw_sysapi: git: url: https://github.com/LinwoodDev/dart_pkgs.git - ref: d7d00d81291d6a2716da40b9f1d004ac4973771d + ref: 4504590d871958c48a93969461193691ea88cb18 path: packages/lw_sysapi setonix_api: path: ../api diff --git a/server/lib/src/bloc.dart b/server/lib/src/bloc.dart index a563bf19..d7e2472e 100644 --- a/server/lib/src/bloc.dart +++ b/server/lib/src/bloc.dart @@ -218,6 +218,20 @@ class WorldBloc extends Bloc ); return; } + if (!isValidClientEvent( + data, + packet.channel, + state, + assetManager: assetManager, + allowManagementRequests: true, + )) { + server.log( + 'Rejected invalid ${data.runtimeType} payload from channel ' + '${packet.channel}.', + level: LogLevel.warning, + ); + return; + } if (await _handleManagementEvent(data, packet.channel)) return; } ServerResponse? process; diff --git a/server/lib/src/services/user/remote.dart b/server/lib/src/services/user/remote.dart index 7a94ab7a..8b392775 100644 --- a/server/lib/src/services/user/remote.dart +++ b/server/lib/src/services/user/remote.dart @@ -17,25 +17,6 @@ final class RemoteUserService extends UserService { ? {'Authorization': 'Bearer $endpointSecret'} : {}; - SetonixUser _decodeUser(String body) { - final data = jsonDecode(body) as Map; - final legacyRole = data['role']; - if (data['roles'] == null && - legacyRole is String && - legacyRole.isNotEmpty) { - data['roles'] = [kDefaultServerRole, legacyRole]; - } - return SetonixUserMapper.fromMap(data); - } - - String? _legacyRole(Set? roles) { - if (roles == null) return null; - return roles.firstWhere( - (role) => role != kDefaultServerRole, - orElse: () => kDefaultServerRole, - ); - } - @override Future getUser(String fingerprint) async { final response = await http @@ -46,7 +27,7 @@ final class RemoteUserService extends UserService { .timeout(requestTimeout); if (response.statusCode >= 200 && response.statusCode < 300) { if (response.body.isEmpty) return null; // No user found - return _decodeUser(response.body); + return SetonixUserMapper.fromJson(response.body); } throw KickMessage.fromString(response.body); } @@ -60,7 +41,7 @@ final class RemoteUserService extends UserService { ) .timeout(requestTimeout); if (response.statusCode == 200) { - return _decodeUser(response.body); + return SetonixUserMapper.fromJson(response.body); } return null; } @@ -80,7 +61,6 @@ final class RemoteUserService extends UserService { final body = jsonEncode({ 'name': name, 'onWhitelist': onWhitelist, - 'role': _legacyRole(roles), 'roles': roles?.toList(growable: false), 'banned': banned, 'bannedUntil': bannedUntil?.millisecondsSinceEpoch, @@ -103,7 +83,6 @@ final class RemoteUserService extends UserService { 'fingerprint': fingerprint, 'name': name, 'onWhitelist': onWhitelist, - 'role': _legacyRole(roles), 'roles': roles?.toList(growable: false), 'banned': banned, 'bannedUntil': bannedUntil?.millisecondsSinceEpoch, diff --git a/server/test/authorization_test.dart b/server/test/authorization_test.dart index a17940a1..3efc4ccb 100644 --- a/server/test/authorization_test.dart +++ b/server/test/authorization_test.dart @@ -77,6 +77,69 @@ void main() { expect(response.main?.data, isA()); }); + test('management payloads enforce text, role, and expiry limits', () { + final state = WorldState(data: SetonixData.empty()); + final assets = _EmptyAssetManager(); + bool valid(ClientWorldEvent event) => isValidClientEvent( + event, + 2, + state, + assetManager: assets, + allowManagementRequests: true, + ); + String text(int length) => List.filled(length, 'a').join(); + + expect(valid(KickPlayerRequest(3, reason: text(2000))), isTrue); + expect(valid(KickPlayerRequest(3, reason: text(2001))), isFalse); + expect( + valid( + BanPlayerRequest( + 3, + expiresAt: DateTime.now().add(const Duration(days: 30)), + ), + ), + isTrue, + ); + expect( + valid( + BanPlayerRequest( + 3, + expiresAt: DateTime.now().subtract(const Duration(minutes: 1)), + ), + ), + isFalse, + ); + expect(valid(BanPlayerRequest(3)), isTrue); + expect( + valid( + BanPlayerRequest( + 3, + expiresAt: DateTime.now().add(const Duration(days: 3651)), + ), + ), + isFalse, + ); + expect( + valid( + ServerRoleChangeRequest( + 3, + roles: {for (var i = 0; i < 33; i++) 'test:role$i'}, + ), + ), + isFalse, + ); + expect(valid(GameRolesChangeRequest(3, {text(257)})), isFalse); + expect( + isValidClientEvent( + ServerRoleChangeRequest(3, roles: {'moderator'}), + 2, + state, + assetManager: assets, + ), + isFalse, + ); + }); + test('new unclassified client events default to play permission', () { final event = ToolbarActionRequest('test'); From 9b55813446fdad2ae5871b3897aef703944fba4f Mon Sep 17 00:00:00 2001 From: CodeDoctorDE Date: Fri, 14 Aug 2026 19:10:57 +0200 Subject: [PATCH 6/8] Add ban list and unban functionality, improve server commands --- api/lib/src/event/client.dart | 8 + api/lib/src/event/event.mapper.dart | 141 +++++++ api/lib/src/event/process/client.dart | 4 + api/lib/src/models/server.dart | 17 + api/lib/src/models/server.mapper.dart | 188 +++++++++ api/lib/src/services/user.dart | 21 + app/lib/l10n/app_en.arb | 6 + app/lib/pages/editor/mode_dialog.dart | 69 ++-- app/lib/pages/game/multiplayer/view.dart | 485 +++++++++++++++-------- plugin/lib/src/events/system.dart | 6 +- server/README.md | 26 ++ server/lib/setonix_server.dart | 4 + server/lib/src/authorization.dart | 2 +- server/lib/src/bloc.dart | 7 + server/lib/src/programs/async.dart | 21 + server/lib/src/programs/ban.dart | 10 +- server/lib/src/programs/bans.dart | 27 ++ server/lib/src/programs/kick.dart | 11 +- server/lib/src/programs/modes.dart | 30 ++ server/lib/src/programs/packs.dart | 4 + server/lib/src/programs/players.dart | 19 +- server/lib/src/programs/reset.dart | 34 +- server/lib/src/programs/role.dart | 10 +- server/lib/src/programs/roles.dart | 10 +- server/lib/src/programs/save.dart | 29 +- server/lib/src/programs/say.dart | 14 +- server/lib/src/programs/scripts.dart | 7 +- server/lib/src/programs/stop.dart | 12 +- server/lib/src/programs/whitelist.dart | 10 +- server/lib/src/programs/worlds.dart | 29 ++ server/lib/src/server.dart | 126 +++++- server/lib/src/services/user/file.dart | 9 + server/test/authorization_test.dart | 10 + server/test/event_system_test.dart | 31 ++ server/test/shutdown_test.dart | 51 +++ server/test/user_role_test.dart | 37 ++ 36 files changed, 1232 insertions(+), 293 deletions(-) create mode 100644 server/lib/src/programs/async.dart create mode 100644 server/lib/src/programs/bans.dart create mode 100644 server/lib/src/programs/modes.dart create mode 100644 server/lib/src/programs/worlds.dart create mode 100644 server/test/event_system_test.dart create mode 100644 server/test/shutdown_test.dart diff --git a/api/lib/src/event/client.dart b/api/lib/src/event/client.dart index ba643038..bf70db4e 100644 --- a/api/lib/src/event/client.dart +++ b/api/lib/src/event/client.dart @@ -219,6 +219,14 @@ final class BanPlayerRequest extends ClientWorldEvent BanPlayerRequest(this.player, {this.reason, this.expiresAt}); } +@MappableClass() +final class UnbanPlayerRequest extends ClientWorldEvent + with UnbanPlayerRequestMappable { + final String userId; + + UnbanPlayerRequest(this.userId); +} + @MappableClass() final class ServerRoleChangeRequest extends ClientWorldEvent with ServerRoleChangeRequestMappable { diff --git a/api/lib/src/event/event.mapper.dart b/api/lib/src/event/event.mapper.dart index 36be556e..960f079a 100644 --- a/api/lib/src/event/event.mapper.dart +++ b/api/lib/src/event/event.mapper.dart @@ -2617,6 +2617,7 @@ class ClientWorldEventMapper extends SubClassMapperBase { ToolbarActionRequestMapper.ensureInitialized(); KickPlayerRequestMapper.ensureInitialized(); BanPlayerRequestMapper.ensureInitialized(); + UnbanPlayerRequestMapper.ensureInitialized(); ServerRoleChangeRequestMapper.ensureInitialized(); GameRolesChangeRequestMapper.ensureInitialized(); HybridWorldEventMapper.ensureInitialized(); @@ -5098,6 +5099,146 @@ class _BanPlayerRequestCopyWithImpl<$R, $Out> ) => _BanPlayerRequestCopyWithImpl<$R2, $Out2>($value, $cast, t); } +/// @nodoc +class UnbanPlayerRequestMapper extends SubClassMapperBase { + UnbanPlayerRequestMapper._(); + + static UnbanPlayerRequestMapper? _instance; + static UnbanPlayerRequestMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = UnbanPlayerRequestMapper._()); + ClientWorldEventMapper.ensureInitialized().addSubMapper(_instance!); + } + return _instance!; + } + + @override + final String id = 'UnbanPlayerRequest'; + + static String _$userId(UnbanPlayerRequest v) => v.userId; + static const Field _f$userId = Field( + 'userId', + _$userId, + ); + + @override + final MappableFields fields = const {#userId: _f$userId}; + + @override + final String discriminatorKey = 'type'; + @override + final dynamic discriminatorValue = 'UnbanPlayerRequest'; + @override + late final ClassMapperBase superMapper = + ClientWorldEventMapper.ensureInitialized(); + + static UnbanPlayerRequest _instantiate(DecodingData data) { + return UnbanPlayerRequest(data.dec(_f$userId)); + } + + @override + final Function instantiate = _instantiate; + + static UnbanPlayerRequest fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static UnbanPlayerRequest fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +/// @nodoc +mixin UnbanPlayerRequestMappable { + String toJson() { + return UnbanPlayerRequestMapper.ensureInitialized() + .encodeJson(this as UnbanPlayerRequest); + } + + Map toMap() { + return UnbanPlayerRequestMapper.ensureInitialized() + .encodeMap(this as UnbanPlayerRequest); + } + + UnbanPlayerRequestCopyWith< + UnbanPlayerRequest, + UnbanPlayerRequest, + UnbanPlayerRequest + > + get copyWith => + _UnbanPlayerRequestCopyWithImpl( + this as UnbanPlayerRequest, + $identity, + $identity, + ); + @override + String toString() { + return UnbanPlayerRequestMapper.ensureInitialized().stringifyValue( + this as UnbanPlayerRequest, + ); + } + + @override + bool operator ==(Object other) { + return UnbanPlayerRequestMapper.ensureInitialized().equalsValue( + this as UnbanPlayerRequest, + other, + ); + } + + @override + int get hashCode { + return UnbanPlayerRequestMapper.ensureInitialized().hashValue( + this as UnbanPlayerRequest, + ); + } +} + +/// @nodoc +extension UnbanPlayerRequestValueCopy<$R, $Out> + on ObjectCopyWith<$R, UnbanPlayerRequest, $Out> { + UnbanPlayerRequestCopyWith<$R, UnbanPlayerRequest, $Out> + get $asUnbanPlayerRequest => $base.as( + (v, t, t2) => _UnbanPlayerRequestCopyWithImpl<$R, $Out>(v, t, t2), + ); +} + +/// @nodoc +abstract class UnbanPlayerRequestCopyWith< + $R, + $In extends UnbanPlayerRequest, + $Out +> + implements ClientWorldEventCopyWith<$R, $In, $Out> { + @override + $R call({String? userId}); + UnbanPlayerRequestCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ); +} + +/// @nodoc +class _UnbanPlayerRequestCopyWithImpl<$R, $Out> + extends ClassCopyWithBase<$R, UnbanPlayerRequest, $Out> + implements UnbanPlayerRequestCopyWith<$R, UnbanPlayerRequest, $Out> { + _UnbanPlayerRequestCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = + UnbanPlayerRequestMapper.ensureInitialized(); + @override + $R call({String? userId}) => + $apply(FieldCopyWithData({if (userId != null) #userId: userId})); + @override + UnbanPlayerRequest $make(CopyWithData data) => + UnbanPlayerRequest(data.get(#userId, or: $value.userId)); + + @override + UnbanPlayerRequestCopyWith<$R2, UnbanPlayerRequest, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ) => _UnbanPlayerRequestCopyWithImpl<$R2, $Out2>($value, $cast, t); +} + /// @nodoc class ServerRoleChangeRequestMapper extends SubClassMapperBase { diff --git a/api/lib/src/event/process/client.dart b/api/lib/src/event/process/client.dart index 88b3ccc4..1c3a55c9 100644 --- a/api/lib/src/event/process/client.dart +++ b/api/lib/src/event/process/client.dart @@ -103,6 +103,9 @@ bool isValidClientEvent( (channel == kAuthorityChannel || allowManagementRequests) && (event.reason?.length ?? 0) <= _maxTextLength && _isPlausibleBanExpiry(event.expiresAt), + UnbanPlayerRequest() => + (channel == kAuthorityChannel || allowManagementRequests) && + _isReasonableIdentifier(event.userId), ServerRoleChangeRequest() => (channel == kAuthorityChannel || allowManagementRequests) && event.roles.length <= _maxRolesPerRequest && @@ -487,6 +490,7 @@ Future processClientEvent( case ToolbarActionRequest(): case KickPlayerRequest(): case BanPlayerRequest(): + case UnbanPlayerRequest(): case ServerRoleChangeRequest(): case GameRolesChangeRequest(): return UpdateServerResponse.builder(null); diff --git a/api/lib/src/models/server.dart b/api/lib/src/models/server.dart index b700f035..8a092390 100644 --- a/api/lib/src/models/server.dart +++ b/api/lib/src/models/server.dart @@ -211,10 +211,26 @@ final class PlayerInfo with PlayerInfoMappable { }); } +@MappableClass() +final class BannedUserInfo with BannedUserInfoMappable { + final String id; + final String name; + final DateTime? expiresAt; + final String? reason; + + const BannedUserInfo({ + required this.id, + required this.name, + this.expiresAt, + this.reason, + }); +} + @MappableClass() final class ServerState with ServerStateMappable { final String? link; final List players; + final List bannedUsers; final Map serverRoles; final Set permissions; final Set assignableServerRoles; @@ -222,6 +238,7 @@ final class ServerState with ServerStateMappable { const ServerState({ this.link, this.players = const [], + this.bannedUsers = const [], this.serverRoles = const {}, this.permissions = const {}, this.assignableServerRoles = const {}, diff --git a/api/lib/src/models/server.mapper.dart b/api/lib/src/models/server.mapper.dart index 93e59022..c89c1a4f 100644 --- a/api/lib/src/models/server.mapper.dart +++ b/api/lib/src/models/server.mapper.dart @@ -1583,6 +1583,163 @@ class _PlayerInfoCopyWithImpl<$R, $Out> ) => _PlayerInfoCopyWithImpl<$R2, $Out2>($value, $cast, t); } +/// @nodoc +class BannedUserInfoMapper extends ClassMapperBase { + BannedUserInfoMapper._(); + + static BannedUserInfoMapper? _instance; + static BannedUserInfoMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = BannedUserInfoMapper._()); + } + return _instance!; + } + + @override + final String id = 'BannedUserInfo'; + + static String _$id(BannedUserInfo v) => v.id; + static const Field _f$id = Field('id', _$id); + static String _$name(BannedUserInfo v) => v.name; + static const Field _f$name = Field('name', _$name); + static DateTime? _$expiresAt(BannedUserInfo v) => v.expiresAt; + static const Field _f$expiresAt = Field( + 'expiresAt', + _$expiresAt, + opt: true, + ); + static String? _$reason(BannedUserInfo v) => v.reason; + static const Field _f$reason = Field( + 'reason', + _$reason, + opt: true, + ); + + @override + final MappableFields fields = const { + #id: _f$id, + #name: _f$name, + #expiresAt: _f$expiresAt, + #reason: _f$reason, + }; + + static BannedUserInfo _instantiate(DecodingData data) { + return BannedUserInfo( + id: data.dec(_f$id), + name: data.dec(_f$name), + expiresAt: data.dec(_f$expiresAt), + reason: data.dec(_f$reason), + ); + } + + @override + final Function instantiate = _instantiate; + + static BannedUserInfo fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static BannedUserInfo fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +/// @nodoc +mixin BannedUserInfoMappable { + String toJson() { + return BannedUserInfoMapper.ensureInitialized().encodeJson( + this as BannedUserInfo, + ); + } + + Map toMap() { + return BannedUserInfoMapper.ensureInitialized().encodeMap( + this as BannedUserInfo, + ); + } + + BannedUserInfoCopyWith + get copyWith => _BannedUserInfoCopyWithImpl( + this as BannedUserInfo, + $identity, + $identity, + ); + @override + String toString() { + return BannedUserInfoMapper.ensureInitialized().stringifyValue( + this as BannedUserInfo, + ); + } + + @override + bool operator ==(Object other) { + return BannedUserInfoMapper.ensureInitialized().equalsValue( + this as BannedUserInfo, + other, + ); + } + + @override + int get hashCode { + return BannedUserInfoMapper.ensureInitialized().hashValue( + this as BannedUserInfo, + ); + } +} + +/// @nodoc +extension BannedUserInfoValueCopy<$R, $Out> + on ObjectCopyWith<$R, BannedUserInfo, $Out> { + BannedUserInfoCopyWith<$R, BannedUserInfo, $Out> get $asBannedUserInfo => + $base.as((v, t, t2) => _BannedUserInfoCopyWithImpl<$R, $Out>(v, t, t2)); +} + +/// @nodoc +abstract class BannedUserInfoCopyWith<$R, $In extends BannedUserInfo, $Out> + implements ClassCopyWith<$R, $In, $Out> { + $R call({String? id, String? name, DateTime? expiresAt, String? reason}); + BannedUserInfoCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ); +} + +/// @nodoc +class _BannedUserInfoCopyWithImpl<$R, $Out> + extends ClassCopyWithBase<$R, BannedUserInfo, $Out> + implements BannedUserInfoCopyWith<$R, BannedUserInfo, $Out> { + _BannedUserInfoCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = + BannedUserInfoMapper.ensureInitialized(); + @override + $R call({ + String? id, + String? name, + Object? expiresAt = $none, + Object? reason = $none, + }) => $apply( + FieldCopyWithData({ + if (id != null) #id: id, + if (name != null) #name: name, + if (expiresAt != $none) #expiresAt: expiresAt, + if (reason != $none) #reason: reason, + }), + ); + @override + BannedUserInfo $make(CopyWithData data) => BannedUserInfo( + id: data.get(#id, or: $value.id), + name: data.get(#name, or: $value.name), + expiresAt: data.get(#expiresAt, or: $value.expiresAt), + reason: data.get(#reason, or: $value.reason), + ); + + @override + BannedUserInfoCopyWith<$R2, BannedUserInfo, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ) => _BannedUserInfoCopyWithImpl<$R2, $Out2>($value, $cast, t); +} + /// @nodoc class ServerStateMapper extends ClassMapperBase { ServerStateMapper._(); @@ -1592,6 +1749,7 @@ class ServerStateMapper extends ClassMapperBase { if (_instance == null) { MapperContainer.globals.use(_instance = ServerStateMapper._()); PlayerInfoMapper.ensureInitialized(); + BannedUserInfoMapper.ensureInitialized(); ServerRoleDefinitionMapper.ensureInitialized(); } return _instance!; @@ -1613,6 +1771,13 @@ class ServerStateMapper extends ClassMapperBase { opt: true, def: const [], ); + static List _$bannedUsers(ServerState v) => v.bannedUsers; + static const Field> _f$bannedUsers = Field( + 'bannedUsers', + _$bannedUsers, + opt: true, + def: const [], + ); static Map _$serverRoles(ServerState v) => v.serverRoles; static const Field> @@ -1642,6 +1807,7 @@ class ServerStateMapper extends ClassMapperBase { final MappableFields fields = const { #link: _f$link, #players: _f$players, + #bannedUsers: _f$bannedUsers, #serverRoles: _f$serverRoles, #permissions: _f$permissions, #assignableServerRoles: _f$assignableServerRoles, @@ -1651,6 +1817,7 @@ class ServerStateMapper extends ClassMapperBase { return ServerState( link: data.dec(_f$link), players: data.dec(_f$players), + bannedUsers: data.dec(_f$bannedUsers), serverRoles: data.dec(_f$serverRoles), permissions: data.dec(_f$permissions), assignableServerRoles: data.dec(_f$assignableServerRoles), @@ -1722,6 +1889,12 @@ abstract class ServerStateCopyWith<$R, $In extends ServerState, $Out> implements ClassCopyWith<$R, $In, $Out> { ListCopyWith<$R, PlayerInfo, PlayerInfoCopyWith<$R, PlayerInfo, PlayerInfo>> get players; + ListCopyWith< + $R, + BannedUserInfo, + BannedUserInfoCopyWith<$R, BannedUserInfo, BannedUserInfo> + > + get bannedUsers; MapCopyWith< $R, String, @@ -1732,6 +1905,7 @@ abstract class ServerStateCopyWith<$R, $In extends ServerState, $Out> $R call({ String? link, List? players, + List? bannedUsers, Map? serverRoles, Set? permissions, Set? assignableServerRoles, @@ -1756,6 +1930,17 @@ class _ServerStateCopyWithImpl<$R, $Out> (v) => call(players: v), ); @override + ListCopyWith< + $R, + BannedUserInfo, + BannedUserInfoCopyWith<$R, BannedUserInfo, BannedUserInfo> + > + get bannedUsers => ListCopyWith( + $value.bannedUsers, + (v, t) => v.copyWith.$chain(t), + (v) => call(bannedUsers: v), + ); + @override MapCopyWith< $R, String, @@ -1771,6 +1956,7 @@ class _ServerStateCopyWithImpl<$R, $Out> $R call({ Object? link = $none, List? players, + List? bannedUsers, Map? serverRoles, Set? permissions, Set? assignableServerRoles, @@ -1778,6 +1964,7 @@ class _ServerStateCopyWithImpl<$R, $Out> FieldCopyWithData({ if (link != $none) #link: link, if (players != null) #players: players, + if (bannedUsers != null) #bannedUsers: bannedUsers, if (serverRoles != null) #serverRoles: serverRoles, if (permissions != null) #permissions: permissions, if (assignableServerRoles != null) @@ -1788,6 +1975,7 @@ class _ServerStateCopyWithImpl<$R, $Out> ServerState $make(CopyWithData data) => ServerState( link: data.get(#link, or: $value.link), players: data.get(#players, or: $value.players), + bannedUsers: data.get(#bannedUsers, or: $value.bannedUsers), serverRoles: data.get(#serverRoles, or: $value.serverRoles), permissions: data.get(#permissions, or: $value.permissions), assignableServerRoles: data.get( diff --git a/api/lib/src/services/user.dart b/api/lib/src/services/user.dart index 5b90b850..d7910e1b 100644 --- a/api/lib/src/services/user.dart +++ b/api/lib/src/services/user.dart @@ -38,6 +38,7 @@ final class SetonixUser with SetonixUserMappable { abstract class UserService { FutureOr getUser(String fingerprint); FutureOr getUserFromName(String name); + FutureOr> getBannedUsers() => const []; FutureOr updateUser( String fingerprint, { String? name, @@ -242,6 +243,26 @@ final class UserManager { return true; } + Future> getBannedUsers() async { + final users = (await service?.getBannedUsers() ?? const []) + .where((user) => user.fingerprint != null && user.isBanned) + .toList(); + users.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + return users; + } + + Future unban(String fingerprint) async { + final user = await service?.getUser(fingerprint); + if (user == null || !user.isBanned) return false; + return await service?.updateUser( + fingerprint, + banned: false, + bannedUntil: null, + banReason: null, + ) ?? + false; + } + Future getUserByReference(String reference) async { if (reference.isEmpty) return null; switch (reference[0]) { diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 2db6b984..29f3aa93 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -17,6 +17,11 @@ "players": "Players", "kick": "Kick", "ban": "Ban", + "unban": "Unban", + "bannedUsers": "Banned users", + "noBannedUsers": "No banned users", + "banExpiresAt": "Until {date}", + "@banExpiresAt": {"placeholders": {"date": {"type": "String"}}}, "reason": "Reason", "banDuration": "Ban duration", "permanent": "Permanent", @@ -24,6 +29,7 @@ "oneDay": "One day", "sevenDays": "Seven days", "gameRoles": "Game roles", + "serverRoles": "Server roles", "commaSeparatedRoles": "Comma-separated roles", "setServerRole": "Set role: {role}", "@setServerRole": {"placeholders": {"role": {"type": "String"}}}, diff --git a/app/lib/pages/editor/mode_dialog.dart b/app/lib/pages/editor/mode_dialog.dart index c76a094d..3aaeecb6 100644 --- a/app/lib/pages/editor/mode_dialog.dart +++ b/app/lib/pages/editor/mode_dialog.dart @@ -74,25 +74,23 @@ class _ModeEditorDialogState extends State { BlocBuilder( builder: (context, state) { final scripts = state.getScripts().toList(); - return DropdownButtonFormField( - decoration: InputDecoration( - labelText: AppLocalizations.of(context).scripts, - filled: true, - ), - initialValue: value.script, - items: [ - DropdownMenuItem( + return DropdownMenu( + expandedInsets: EdgeInsets.zero, + label: Text(AppLocalizations.of(context).scripts), + initialSelection: value.script, + dropdownMenuEntries: [ + DropdownMenuEntry( value: null, - child: Text(AppLocalizations.of(context).none), + label: AppLocalizations.of(context).none, ), ...scripts.map( - (script) => DropdownMenuItem( + (script) => DropdownMenuEntry( value: script, - child: Text(script), + label: script, ), ), ], - onChanged: (newScript) { + onSelected: (newScript) { setState(() { _value = value.copyWith(script: newScript); }); @@ -335,23 +333,21 @@ class _ModeTeamEditorDialogState extends State<_ModeTeamEditorDialog> { setState(() => _team = _team.copyWith(description: val)), ), const SizedBox(height: 8), - DropdownButtonFormField( - decoration: InputDecoration( - labelText: AppLocalizations.of(context).color, - filled: true, - ), - initialValue: _team.color, - items: [ - DropdownMenuItem( + DropdownMenu( + expandedInsets: EdgeInsets.zero, + label: Text(AppLocalizations.of(context).color), + initialSelection: _team.color, + dropdownMenuEntries: [ + DropdownMenuEntry( value: null, - child: Text(AppLocalizations.of(context).none), + label: AppLocalizations.of(context).none, ), ...TeamColor.values.map( - (c) => DropdownMenuItem(value: c, child: Text(c.name)), + (color) => DropdownMenuEntry(value: color, label: color.name), ), ], - onChanged: (val) => - setState(() => _team = _team.copyWith(color: val)), + onSelected: (color) => + setState(() => _team = _team.copyWith(color: color)), ), ], ), @@ -418,28 +414,27 @@ class _ModeTableEditorDialogState extends State<_ModeTableEditorDialog> { builder: (context, state) { final backgrounds = state.getBackgrounds().toList(); final currentBg = _table.background?.toString(); - return DropdownButtonFormField( - decoration: InputDecoration( - labelText: AppLocalizations.of(context).backgrounds, - filled: true, - ), - initialValue: backgrounds.contains(currentBg) + return DropdownMenu( + expandedInsets: EdgeInsets.zero, + label: Text(AppLocalizations.of(context).backgrounds), + initialSelection: backgrounds.contains(currentBg) ? currentBg : null, - items: [ - DropdownMenuItem( + dropdownMenuEntries: [ + DropdownMenuEntry( value: null, - child: Text(AppLocalizations.of(context).none), + label: AppLocalizations.of(context).none, ), ...backgrounds.map( - (bg) => DropdownMenuItem(value: bg, child: Text(bg)), + (background) => + DropdownMenuEntry(value: background, label: background), ), ], - onChanged: (val) => setState(() { + onSelected: (background) => setState(() { _table = _table.copyWith( - background: val == null + background: background == null ? null - : ItemLocation.fromString(val), + : ItemLocation.fromString(background), ); }), ); diff --git a/app/lib/pages/game/multiplayer/view.dart b/app/lib/pages/game/multiplayer/view.dart index 2da54f05..cb46ac38 100644 --- a/app/lib/pages/game/multiplayer/view.dart +++ b/app/lib/pages/game/multiplayer/view.dart @@ -37,30 +37,29 @@ class ViewMultiplayerDialog extends StatelessWidget { ), if (ban) ...[ const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: duration, - decoration: InputDecoration( - labelText: AppLocalizations.of(context).banDuration, - ), - items: [ - DropdownMenuItem( + DropdownMenu( + initialSelection: duration, + expandedInsets: EdgeInsets.zero, + label: Text(AppLocalizations.of(context).banDuration), + dropdownMenuEntries: [ + DropdownMenuEntry( value: null, - child: Text(AppLocalizations.of(context).permanent), + label: AppLocalizations.of(context).permanent, ), - DropdownMenuItem( + DropdownMenuEntry( value: const Duration(hours: 1), - child: Text(AppLocalizations.of(context).oneHour), + label: AppLocalizations.of(context).oneHour, ), - DropdownMenuItem( + DropdownMenuEntry( value: const Duration(days: 1), - child: Text(AppLocalizations.of(context).oneDay), + label: AppLocalizations.of(context).oneDay, ), - DropdownMenuItem( + DropdownMenuEntry( value: const Duration(days: 7), - child: Text(AppLocalizations.of(context).sevenDays), + label: AppLocalizations.of(context).sevenDays, ), ], - onChanged: (value) => setState(() => duration = value), + onSelected: (value) => setState(() => duration = value), ), ], ], @@ -145,6 +144,51 @@ class ViewMultiplayerDialog extends StatelessWidget { ); } + void _toggleServerRole(BuildContext context, PlayerInfo player, String role) { + final updatedRoles = Set.from(player.serverRoles); + if (updatedRoles.contains(role)) { + updatedRoles.remove(role); + } else { + updatedRoles.add(role); + } + unawaited( + context.read().process( + ServerRoleChangeRequest(player.id, roles: updatedRoles), + ), + ); + } + + Future _unbanUser(BuildContext context, BannedUserInfo user) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context).unban), + content: Text(user.name), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(AppLocalizations.of(context).unban), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + unawaited(context.read().process(UnbanPlayerRequest(user.id))); + } + + String _formatBanExpiration(BuildContext context, DateTime expiresAt) { + final local = expiresAt.toLocal(); + final material = MaterialLocalizations.of(context); + final formatted = + '${material.formatMediumDate(local)} ' + '${material.formatTimeOfDay(TimeOfDay.fromDateTime(local))}'; + return AppLocalizations.of(context).banExpiresAt(formatted); + } + @override Widget build(BuildContext context) { final info = FutureBuilder( @@ -223,170 +267,261 @@ class ViewMultiplayerDialog extends StatelessWidget { ) .toList(growable: false) : const []; - if (players.isEmpty) { - return Text( - AppLocalizations.of(context).noConnections, - textAlign: TextAlign.center, - ); - } - return ListView.builder( - itemCount: players.length, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) { - final player = players[index]; - final gameRoles = worldState.world.getGameRoles(player.id); - final roleNames = player.serverRoles - .map( - (role) => worldState.assetManager - .getRoleTranslation( - role, - fallback: serverState.serverRoles[role]?.name, + final canViewBans = serverState.permissions.contains( + ServerPermission.banPlayers, + ); + final playerList = players.isEmpty + ? Padding( + padding: const EdgeInsets.all(16), + child: Text( + AppLocalizations.of(context).noConnections, + textAlign: TextAlign.center, + ), + ) + : ListView.builder( + itemCount: players.length, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + final player = players[index]; + final gameRoles = worldState.world.getGameRoles( + player.id, + ); + final roleNames = player.serverRoles + .map( + (role) => worldState.assetManager + .getRoleTranslation( + role, + fallback: + serverState.serverRoles[role]?.name, + ) + .name, ) - .name, - ) - .toList(growable: false); - final gameRoleNames = gameRoles - .map( - (role) => worldState.assetManager - .getRoleTranslation(role) - .name, - ) - .toList(growable: false); - final canKick = - player.manageable && - serverState.permissions.contains( - ServerPermission.kickPlayers, - ); - final canBan = - player.registered && - player.manageable && - serverState.permissions.contains( - ServerPermission.banPlayers, - ); - final canManageRoles = - player.manageable && - serverState.permissions.contains( - ServerPermission.manageRoles, - ); - final canManageGameRoles = serverState.permissions.contains( - ServerPermission.manageWorld, - ); - final canManage = - canKick || canBan || canManageRoles || canManageGameRoles; - return ListTile( - title: Text( - player.name ?? - AppLocalizations.of( - context, - ).defaultUserName(player.id), - ), - subtitle: Text( - [ - roleNames.join(', '), - if (gameRoleNames.isNotEmpty) gameRoleNames.join(', '), - ].join(' · '), - ), - trailing: canManage - ? PopupMenuButton( - onSelected: (value) { - if (value == 'kick') { - _moderatePlayer(context, player, ban: false); - } else if (value == 'ban') { - _moderatePlayer(context, player, ban: true); - } else if (value == 'gameRoles') { - _changeGameRoles(context, player, gameRoles); - } else if (value.startsWith('role:')) { - final role = value.substring(5); - final updatedRoles = Set.from( - player.serverRoles, - ); - if (updatedRoles.contains(role) && - role != kDefaultServerRole) { - updatedRoles.remove(role); - } else { - updatedRoles.add(role); - } - unawaited( - context.read().process( - ServerRoleChangeRequest( - player.id, - roles: updatedRoles, - ), - ), - ); - } - }, - itemBuilder: (context) => [ - if (canKick) - PopupMenuItem( - value: 'kick', - child: Text( - AppLocalizations.of(context).kick, - ), - ), - if (canBan) - PopupMenuItem( - value: 'ban', - child: Text(AppLocalizations.of(context).ban), - ), - if (canManageGameRoles) - PopupMenuItem( - value: 'gameRoles', - child: Text( - AppLocalizations.of(context).gameRoles, + .toList(growable: false); + final gameRoleNames = gameRoles + .map( + (role) => worldState.assetManager + .getRoleTranslation(role) + .name, + ) + .toList(growable: false); + final canKick = + player.manageable && + serverState.permissions.contains( + ServerPermission.kickPlayers, + ); + final canBan = + player.registered && + player.manageable && + serverState.permissions.contains( + ServerPermission.banPlayers, + ); + final canManageRoles = + player.manageable && + serverState.permissions.contains( + ServerPermission.manageRoles, + ); + final canManageGameRoles = serverState.permissions + .contains(ServerPermission.manageWorld); + final canManage = + canKick || + canBan || + canManageRoles || + canManageGameRoles; + return ListTile( + title: Text( + player.name ?? + AppLocalizations.of( + context, + ).defaultUserName(player.id), + ), + subtitle: Text( + [ + roleNames.join(', '), + if (gameRoleNames.isNotEmpty) + gameRoleNames.join(', '), + ].join(' · '), + ), + trailing: canManage + ? MenuAnchor( + builder: defaultMenuButton( + tooltip: MaterialLocalizations.of( + context, + ).showMenuTooltip, ), - ), - if (canManageRoles) - ...serverState.serverRoles.entries - .where( - (entry) => serverState - .assignableServerRoles - .contains(entry.key), - ) - .map((entry) { - final translatedRole = worldState - .assetManager - .getRoleTranslation( - entry.key, - fallback: entry.value.name, - ) - .name; - final translatedPermissions = entry - .value - .permissions - .map( - (permission) => worldState - .assetManager - .getPermissionTranslation( - permission, - ) - .name, - ) - .join(', '); - return CheckedPopupMenuItem( - value: 'role:${entry.key}', - checked: player.serverRoles.contains( - entry.key, + menuChildren: [ + if (canKick) + MenuItemButton( + leadingIcon: const PhosphorIcon( + PhosphorIconsLight.userMinus, ), - enabled: - entry.key != kDefaultServerRole, - child: ListTile( - contentPadding: EdgeInsets.zero, - dense: true, - title: Text(translatedRole), - subtitle: - translatedPermissions.isEmpty - ? null - : Text(translatedPermissions), + onPressed: () => _moderatePlayer( + context, + player, + ban: false, ), - ); - }), - ], - ) - : null, - ); - }, + child: Text( + AppLocalizations.of(context).kick, + ), + ), + if (canBan) + MenuItemButton( + leadingIcon: const PhosphorIcon( + PhosphorIconsLight.gavel, + ), + onPressed: () => _moderatePlayer( + context, + player, + ban: true, + ), + child: Text( + AppLocalizations.of(context).ban, + ), + ), + if (canManageGameRoles) + MenuItemButton( + leadingIcon: const PhosphorIcon( + PhosphorIconsLight.gameController, + ), + onPressed: () => _changeGameRoles( + context, + player, + gameRoles, + ), + child: Text( + AppLocalizations.of( + context, + ).gameRoles, + ), + ), + if (canManageRoles) + SubmenuButton( + leadingIcon: const PhosphorIcon( + PhosphorIconsLight + .identificationBadge, + ), + menuChildren: serverState + .serverRoles + .entries + .where( + (entry) => serverState + .assignableServerRoles + .contains(entry.key), + ) + .map((entry) { + final translatedRole = worldState + .assetManager + .getRoleTranslation( + entry.key, + fallback: entry.value.name, + ) + .name; + final translatedPermissions = entry + .value + .permissions + .map( + (permission) => worldState + .assetManager + .getPermissionTranslation( + permission, + ) + .name, + ) + .join(', '); + return CheckboxMenuButton( + value: player.serverRoles + .contains(entry.key), + closeOnActivate: false, + onChanged: + entry.key == + kDefaultServerRole + ? null + : (_) => _toggleServerRole( + context, + player, + entry.key, + ), + child: ListTile( + contentPadding: + EdgeInsets.zero, + dense: true, + title: Text(translatedRole), + subtitle: + translatedPermissions + .isEmpty + ? null + : Text( + translatedPermissions, + ), + ), + ); + }) + .toList(growable: false), + child: Text( + AppLocalizations.of( + context, + ).serverRoles, + ), + ), + ], + ) + : null, + ); + }, + ); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + playerList, + if (canViewBans) ...[ + const Divider(), + ExpansionTile( + leading: const PhosphorIcon(PhosphorIconsLight.gavel), + title: Text(AppLocalizations.of(context).bannedUsers), + initiallyExpanded: serverState.bannedUsers.isNotEmpty, + children: serverState.bannedUsers.isEmpty + ? [ + ListTile( + title: Text( + AppLocalizations.of(context).noBannedUsers, + ), + ), + ] + : serverState.bannedUsers + .map( + (user) => ListTile( + title: Text(user.name), + subtitle: Text( + [ + ?user.reason, + if (user.expiresAt + case final expiresAt?) + _formatBanExpiration( + context, + expiresAt, + ) + else + AppLocalizations.of( + context, + ).permanent, + ].join('\n'), + ), + trailing: IconButton.filledTonal( + icon: const PhosphorIcon( + PhosphorIconsLight.lockOpen, + ), + tooltip: AppLocalizations.of( + context, + ).unban, + onPressed: () => + _unbanUser(context, user), + ), + ), + ) + .toList(growable: false), + ), + ], + ], ); }, ), diff --git a/plugin/lib/src/events/system.dart b/plugin/lib/src/events/system.dart index ecc57366..a968f7cb 100644 --- a/plugin/lib/src/events/system.dart +++ b/plugin/lib/src/events/system.dart @@ -79,18 +79,20 @@ final class EventSystem extends EventBus { Stream get leave => _leaveController.stream; GameProperty runPing(HttpRequest request, GameProperty property) { + if (_pingController.isClosed) return property; final ping = ServerPing(request: request, response: property); _pingController.add(ping); return ping.response; } void runLeaveCallback(Channel channel, ConnectionInfo info) { + if (_leaveController.isClosed) return; final callback = UserLeaveCallback(channel: channel, info: info); _leaveController.add(callback); } void dispose() { - _pingController.close(); - _leaveController.close(); + if (!_pingController.isClosed) _pingController.close(); + if (!_leaveController.isClosed) _leaveController.close(); } } diff --git a/server/README.md b/server/README.md index 91cf1181..67809d37 100644 --- a/server/README.md +++ b/server/README.md @@ -129,3 +129,29 @@ example above, add this to `translations/en.json` in the `blackjack` pack: } } ``` + +## Console commands + +The server console exposes the following runtime management commands: + +```text +players [world] +bans +ban [minutes] [reason...] +unban +kick [reason] [link] +worlds +modes +reset [world] [game-mode|sandbox] +save [world] +scripts [world] +packs +say +whitelist +role [roles...] +roles ... +stop +``` + +User references may use a channel (`#2`), name (`@Player`), fingerprint +(`*fingerprint`), or the unprefixed forms accepted by the individual command. diff --git a/server/lib/setonix_server.dart b/server/lib/setonix_server.dart index 503925a5..fd636a1d 100644 --- a/server/lib/setonix_server.dart +++ b/server/lib/setonix_server.dart @@ -9,6 +9,9 @@ export 'src/bloc.dart'; export 'src/main.dart'; export 'src/server.dart'; export 'src/programs/kick.dart'; +export 'src/programs/ban.dart'; +export 'src/programs/bans.dart'; +export 'src/programs/modes.dart'; export 'src/programs/packs.dart'; export 'src/programs/players.dart'; export 'src/programs/role.dart'; @@ -18,4 +21,5 @@ export 'src/programs/save.dart'; export 'src/programs/say.dart'; export 'src/programs/stop.dart'; export 'src/programs/whitelist.dart'; +export 'src/programs/worlds.dart'; export 'package:networker/networker.dart'; diff --git a/server/lib/src/authorization.dart b/server/lib/src/authorization.dart index 368080ef..fd89061c 100644 --- a/server/lib/src/authorization.dart +++ b/server/lib/src/authorization.dart @@ -14,7 +14,7 @@ String? requiredPermission(WorldEvent event) => switch (event) { TableRenamed() || TableRemoved() => ServerPermission.manageWorld, KickPlayerRequest() => ServerPermission.kickPlayers, - BanPlayerRequest() => ServerPermission.banPlayers, + BanPlayerRequest() || UnbanPlayerRequest() => ServerPermission.banPlayers, ServerRoleChangeRequest() => ServerPermission.manageRoles, GameRolesChangeRequest() => ServerPermission.manageWorld, _ => ServerPermission.play, diff --git a/server/lib/src/bloc.dart b/server/lib/src/bloc.dart index d7e2472e..fae7ab0a 100644 --- a/server/lib/src/bloc.dart +++ b/server/lib/src/bloc.dart @@ -315,6 +315,13 @@ class WorldBloc extends Bloc Channel actorId, ) async { final actor = server.userManager.getUser(actorId); + if (event case UnbanPlayerRequest()) { + if (actor == null) return true; + if (await server.userManager.unban(event.userId)) { + await server.broadcastAllServerStates(); + } + return true; + } final targetId = switch (event) { KickPlayerRequest() => event.player, BanPlayerRequest() => event.player, diff --git a/server/lib/src/programs/async.dart b/server/lib/src/programs/async.dart new file mode 100644 index 00000000..d29a7231 --- /dev/null +++ b/server/lib/src/programs/async.dart @@ -0,0 +1,21 @@ +import 'dart:async'; + +import 'package:consoler/consoler.dart'; +import 'package:setonix_server/src/server.dart'; + +abstract class AsyncServerProgram extends ConsoleProgram { + final SetonixServer server; + + AsyncServerProgram(this.server); + + Future runAsync(String label, List args); + + @override + void run(String label, List args) { + unawaited( + runAsync(label, args).catchError((Object error, StackTrace stackTrace) { + server.log('Command $label failed: $error', level: LogLevel.error); + }), + ); + } +} diff --git a/server/lib/src/programs/ban.dart b/server/lib/src/programs/ban.dart index eeb922f2..67e83da9 100644 --- a/server/lib/src/programs/ban.dart +++ b/server/lib/src/programs/ban.dart @@ -1,12 +1,11 @@ import 'package:consoler/consoler.dart'; import 'package:setonix_api/setonix_api.dart'; -import 'package:setonix_server/src/server.dart'; +import 'package:setonix_server/src/programs/async.dart'; -class BanProgram extends ConsoleProgram { - final SetonixServer server; +class BanProgram extends AsyncServerProgram { final bool banned; - BanProgram(this.server, {required this.banned}); + BanProgram(super.server, {required this.banned}); @override String getUsage() => banned ? ' [Minutes] [Reason...]' : ''; @@ -17,7 +16,7 @@ class BanProgram extends ConsoleProgram { : 'Remove a persistent user ban.'; @override - Future run(String label, List args) async { + Future runAsync(String label, List args) async { if (args.isEmpty || (!banned && args.length != 1)) { server.log('Wrong usage, use ${getUsage()}', level: LogLevel.error); return; @@ -50,6 +49,7 @@ class BanProgram extends ConsoleProgram { KickMessage(reason: KickReason.ban, message: reason), ); } + await server.broadcastAllServerStates(); server.log( '${banned ? 'Banned' : 'Unbanned'} ${args.first}', level: LogLevel.info, diff --git a/server/lib/src/programs/bans.dart b/server/lib/src/programs/bans.dart new file mode 100644 index 00000000..b872f3c9 --- /dev/null +++ b/server/lib/src/programs/bans.dart @@ -0,0 +1,27 @@ +import 'package:consoler/consoler.dart'; +import 'package:setonix_server/src/programs/async.dart'; + +class BansProgram extends AsyncServerProgram { + BansProgram(super.server); + + @override + String getDescription() => 'List active user bans.'; + + @override + Future runAsync(String label, List args) async { + if (args.isNotEmpty) { + server.log('Wrong usage, use bans', level: LogLevel.error); + return; + } + final users = await server.userManager.getBannedUsers(); + server.log('${users.length} active ban(s):', level: LogLevel.info); + for (final user in users) { + final expiry = + user.bannedUntil?.toLocal().toIso8601String() ?? 'permanent'; + final reason = user.banReason?.isNotEmpty == true + ? ' | ${user.banReason}' + : ''; + server.log('@${user.name} | until $expiry$reason', level: LogLevel.info); + } + } +} diff --git a/server/lib/src/programs/kick.dart b/server/lib/src/programs/kick.dart index 175b7f00..6f28f2b5 100644 --- a/server/lib/src/programs/kick.dart +++ b/server/lib/src/programs/kick.dart @@ -1,11 +1,9 @@ import 'package:consoler/consoler.dart'; import 'package:setonix_api/event.dart'; -import 'package:setonix_server/src/server.dart'; +import 'package:setonix_server/src/programs/async.dart'; -class KickProgram extends ConsoleProgram { - final SetonixServer server; - - KickProgram(this.server); +class KickProgram extends AsyncServerProgram { + KickProgram(super.server); @override String getDescription() => "Kick a player"; @@ -14,9 +12,10 @@ class KickProgram extends ConsoleProgram { String getUsage() => ' [] []'; @override - Future run(String label, List args) async { + Future runAsync(String label, List args) async { if (args.isEmpty || args.length > 3) { server.log("Wrong usage, use ${getUsage()}", level: LogLevel.error); + return; } final arg = await server.userManager.getUserIdByReference(args[0]); if (arg == null) { diff --git a/server/lib/src/programs/modes.dart b/server/lib/src/programs/modes.dart new file mode 100644 index 00000000..ae03fac7 --- /dev/null +++ b/server/lib/src/programs/modes.dart @@ -0,0 +1,30 @@ +import 'package:consoler/consoler.dart'; +import 'package:setonix_api/setonix_api.dart'; +import 'package:setonix_server/src/server.dart'; + +class ModesProgram extends ConsoleProgram { + final SetonixServer server; + + ModesProgram(this.server); + + @override + String getDescription() => 'List available game modes.'; + + @override + void run(String label, List args) { + if (args.isNotEmpty) { + server.log('Wrong usage, use modes', level: LogLevel.error); + return; + } + final modes = server.assetManager.packs + .expand( + (pack) => + pack.value.getModes().map((mode) => ItemLocation(pack.key, mode)), + ) + .toList(growable: false); + server.log('${modes.length} available game mode(s):', level: LogLevel.info); + for (final mode in modes) { + server.log(mode, level: LogLevel.info); + } + } +} diff --git a/server/lib/src/programs/packs.dart b/server/lib/src/programs/packs.dart index d6b73520..8abc9501 100644 --- a/server/lib/src/programs/packs.dart +++ b/server/lib/src/programs/packs.dart @@ -12,6 +12,10 @@ class PacksProgram extends ConsoleProgram { @override void run(String label, List args) { + if (args.isNotEmpty) { + server.log('Wrong usage, use packs', level: LogLevel.error); + return; + } print("-----"); final packs = server.assetManager.packs.toList(); print("Loaded ${packs.length} pack(s)."); diff --git a/server/lib/src/programs/players.dart b/server/lib/src/programs/players.dart index 20199013..10ace17c 100644 --- a/server/lib/src/programs/players.dart +++ b/server/lib/src/programs/players.dart @@ -9,15 +9,30 @@ class PlayersProgram extends ConsoleProgram { @override String getDescription() => "Show all connected players"; + @override + String getUsage() => '[]'; + @override void run(String label, List args) { + if (args.length > 1) { + server.log('Wrong usage, use ${getUsage()}', level: LogLevel.error); + return; + } + final worldName = args.firstOrNull; + if (worldName != null && server.getWorld(worldName) == null) { + server.log('Unknown world: $worldName', level: LogLevel.error); + return; + } print("-----"); - final players = server.userManager.getUsers(); + final players = server.userManager.getUsers().where( + (player) => + worldName == null || server.getUserWorldName(player.key) == worldName, + ); print("${players.length} player(s) connected."); for (final player in players) { final connectionInfo = server.players[player.key]; print( - "${connectionInfo?.address} | #${player.key}) | @${player.value.name} | ${player.value.roles.join(', ')} | *${player.value.fingerprint ?? 'No Fingerprint'}", + "${connectionInfo?.address} | #${player.key} | @${player.value.name} | ${player.value.roles.join(', ')} | *${player.value.fingerprint ?? 'No Fingerprint'}", ); } print("-----"); diff --git a/server/lib/src/programs/reset.dart b/server/lib/src/programs/reset.dart index 0e6f67b0..78e7a496 100644 --- a/server/lib/src/programs/reset.dart +++ b/server/lib/src/programs/reset.dart @@ -1,26 +1,40 @@ import 'package:consoler/consoler.dart'; import 'package:setonix_server/setonix_server.dart'; +import 'package:setonix_server/src/programs/async.dart'; -class ResetProgram extends ConsoleProgram { - final SetonixServer server; - - ResetProgram(this.server); +class ResetProgram extends AsyncServerProgram { + ResetProgram(super.server); @override String getDescription() => "Reset the world. Specify a game mode to allow playing games."; @override - String getUsage() => '[]'; + String getUsage() => '[] [|sandbox]'; @override - Future run(String label, List args) async { - if (args.length > 1) { + Future runAsync(String label, List args) async { + if (args.length > 2) { server.log("Wrong usage, use ${getUsage()}", level: LogLevel.error); + return; } final worldName = args.elementAtOrNull(0) ?? defaultWorldName; - server.log("Resetting world...", level: LogLevel.info); - await server.getWorld(worldName)?.resetWorld(); - server.log("World reset successful", level: LogLevel.info); + final world = server.getWorld(worldName); + if (world == null) { + server.log('Unknown world: $worldName', level: LogLevel.error); + return; + } + final modeName = args.elementAtOrNull(1); + ItemLocation? mode; + if (modeName != null && modeName != 'sandbox') { + mode = ItemLocation.fromString(modeName); + if (server.assetManager.getModeItem(mode) == null) { + server.log('Unknown game mode: $modeName', level: LogLevel.error); + return; + } + } + server.log('Resetting world $worldName...', level: LogLevel.info); + await world.resetWorld(mode); + server.log('World $worldName reset successfully.', level: LogLevel.info); } } diff --git a/server/lib/src/programs/role.dart b/server/lib/src/programs/role.dart index 272c9c40..8e48adeb 100644 --- a/server/lib/src/programs/role.dart +++ b/server/lib/src/programs/role.dart @@ -1,11 +1,9 @@ import 'package:consoler/consoler.dart'; import 'package:setonix_api/setonix_api.dart'; -import 'package:setonix_server/src/server.dart'; +import 'package:setonix_server/src/programs/async.dart'; -class RoleProgram extends ConsoleProgram { - final SetonixServer server; - - RoleProgram(this.server); +class RoleProgram extends AsyncServerProgram { + RoleProgram(super.server); @override String getUsage() => @@ -15,7 +13,7 @@ class RoleProgram extends ConsoleProgram { String getDescription() => 'List, add, remove, or set user roles.'; @override - Future run(String label, List args) async { + Future runAsync(String label, List args) async { if (args.length < 2) { server.log('Wrong usage, use ${getUsage()}', level: LogLevel.error); return; diff --git a/server/lib/src/programs/roles.dart b/server/lib/src/programs/roles.dart index bca62975..3cd6665d 100644 --- a/server/lib/src/programs/roles.dart +++ b/server/lib/src/programs/roles.dart @@ -1,11 +1,9 @@ import 'package:consoler/consoler.dart'; import 'package:setonix_api/setonix_api.dart'; -import 'package:setonix_server/src/server.dart'; +import 'package:setonix_server/src/programs/async.dart'; -class RolesProgram extends ConsoleProgram { - final SetonixServer server; - - RolesProgram(this.server); +class RolesProgram extends AsyncServerProgram { + RolesProgram(super.server); @override String getUsage() => @@ -33,7 +31,7 @@ roles permission ''', level: LogLevel.info); } @override - Future run(String label, List args) async { + Future runAsync(String label, List args) async { final action = args.firstOrNull?.toLowerCase() ?? 'list'; final roles = Map.from( server.configManager.serverRoles, diff --git a/server/lib/src/programs/save.dart b/server/lib/src/programs/save.dart index a9b5d47f..6fbea900 100644 --- a/server/lib/src/programs/save.dart +++ b/server/lib/src/programs/save.dart @@ -1,17 +1,32 @@ import 'package:consoler/consoler.dart'; -import 'package:setonix_server/src/server.dart'; +import 'package:setonix_server/src/programs/async.dart'; -class SaveProgram extends ConsoleProgram { - final SetonixServer server; - - SaveProgram(this.server); +class SaveProgram extends AsyncServerProgram { + SaveProgram(super.server); @override String getDescription() => "Saves the world manually"; @override - Future run(String label, List args) async { + String getUsage() => '[]'; + + @override + Future runAsync(String label, List args) async { + if (args.length > 1) { + server.log('Wrong usage, use ${getUsage()}', level: LogLevel.error); + return; + } + final worldName = args.firstOrNull; + final world = worldName == null ? null : server.getWorld(worldName); + if (worldName != null && world == null) { + server.log('Unknown world: $worldName', level: LogLevel.error); + return; + } server.consoler.print('Saving...', level: LogLevel.info); - await server.saveAll(force: true); + if (world == null) { + await server.saveAll(force: true); + } else { + await world.save(force: true); + } server.consoler.print('Saved.', level: LogLevel.info); } } diff --git a/server/lib/src/programs/say.dart b/server/lib/src/programs/say.dart index f6a38cca..1421af84 100644 --- a/server/lib/src/programs/say.dart +++ b/server/lib/src/programs/say.dart @@ -1,17 +1,19 @@ import 'package:consoler/consoler.dart'; import 'package:setonix_api/setonix_api.dart'; -import 'package:setonix_server/src/server.dart'; +import 'package:setonix_server/src/programs/async.dart'; -class SayProgram extends ConsoleProgram { - final SetonixServer server; - - SayProgram(this.server); +class SayProgram extends AsyncServerProgram { + SayProgram(super.server); @override String getDescription() => "Send a message in the message"; @override - Future run(String label, List args) async { + Future runAsync(String label, List args) async { + if (args.isEmpty) { + server.log('Wrong usage, use say ', level: LogLevel.error); + return; + } final message = args.join(' '); server.consoler.print("Sent $message", level: LogLevel.info); server.process(MessageRequest(message)); diff --git a/server/lib/src/programs/scripts.dart b/server/lib/src/programs/scripts.dart index 4a577ea2..3e956eb9 100644 --- a/server/lib/src/programs/scripts.dart +++ b/server/lib/src/programs/scripts.dart @@ -17,8 +17,13 @@ class ScriptsProgram extends ConsoleProgram { String world = defaultWorldName; if (args.length > 1) return print("Usage: $label [world]"); if (args.length == 1) world = args[0]; + final loadedWorld = server.getWorld(world); + if (loadedWorld == null) { + server.log('Unknown world: $world', level: LogLevel.error); + return; + } print("-----"); - final scripts = server.getWorld(world)?.pluginSystem.plugins.toList() ?? []; + final scripts = loadedWorld.pluginSystem.plugins.toList(); print("Loaded ${scripts.length} script(s)."); for (final script in scripts) { print("> $script"); diff --git a/server/lib/src/programs/stop.dart b/server/lib/src/programs/stop.dart index 0cdd0473..86ce030f 100644 --- a/server/lib/src/programs/stop.dart +++ b/server/lib/src/programs/stop.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:consoler/consoler.dart'; import 'package:setonix_server/src/server.dart'; @@ -10,6 +12,14 @@ class StopProgram extends ConsoleProgram { @override void run(String label, List args) { - server.close(); + if (args.isNotEmpty) { + server.log('Wrong usage, use stop', level: LogLevel.error); + return; + } + unawaited( + server.close().catchError((Object error, StackTrace stackTrace) { + server.log('Failed to stop server: $error', level: LogLevel.error); + }), + ); } } diff --git a/server/lib/src/programs/whitelist.dart b/server/lib/src/programs/whitelist.dart index 7e02b79e..d596fa32 100644 --- a/server/lib/src/programs/whitelist.dart +++ b/server/lib/src/programs/whitelist.dart @@ -1,10 +1,8 @@ import 'package:consoler/consoler.dart'; -import 'package:setonix_server/src/server.dart'; +import 'package:setonix_server/src/programs/async.dart'; -class WhitelistProgram extends ConsoleProgram { - final SetonixServer server; - - WhitelistProgram(this.server); +class WhitelistProgram extends AsyncServerProgram { + WhitelistProgram(super.server); @override String getUsage() => ' '; @@ -15,7 +13,7 @@ class WhitelistProgram extends ConsoleProgram { "Use 'add ' to add a user and 'remove ' to remove a user."; @override - Future run(String label, List args) async { + Future runAsync(String label, List args) async { if (args.length != 2) { server.log("Wrong usage, use ${getUsage()}", level: LogLevel.error); return; diff --git a/server/lib/src/programs/worlds.dart b/server/lib/src/programs/worlds.dart new file mode 100644 index 00000000..da67cc47 --- /dev/null +++ b/server/lib/src/programs/worlds.dart @@ -0,0 +1,29 @@ +import 'package:consoler/consoler.dart'; +import 'package:setonix_server/src/server.dart'; + +class WorldsProgram extends ConsoleProgram { + final SetonixServer server; + + WorldsProgram(this.server); + + @override + String getDescription() => 'List loaded worlds.'; + + @override + void run(String label, List args) { + if (args.isNotEmpty) { + server.log('Wrong usage, use worlds', level: LogLevel.error); + return; + } + final names = server.worldNames.toList(growable: false); + server.log('${names.length} loaded world(s):', level: LogLevel.info); + for (final name in names) { + final world = server.getWorld(name)!; + final mode = world.state.info.gameMode?.toString() ?? 'sandbox'; + server.log( + '$name | ${world.players.length} player(s) | $mode', + level: LogLevel.info, + ); + } + } +} diff --git a/server/lib/src/server.dart b/server/lib/src/server.dart index 4d83bba0..d53546c4 100644 --- a/server/lib/src/server.dart +++ b/server/lib/src/server.dart @@ -12,6 +12,8 @@ import 'package:setonix_server/src/bloc.dart'; import 'package:setonix_server/src/config.dart'; import 'package:setonix_server/src/programs/kick.dart'; import 'package:setonix_server/src/programs/ban.dart'; +import 'package:setonix_server/src/programs/bans.dart'; +import 'package:setonix_server/src/programs/modes.dart'; import 'package:setonix_server/src/programs/packs.dart'; import 'package:setonix_server/src/programs/players.dart'; import 'package:setonix_server/src/programs/reset.dart'; @@ -23,6 +25,7 @@ import 'package:setonix_server/src/programs/scripts.dart'; import 'package:setonix_server/src/programs/stop.dart'; import 'package:setonix_plugin/setonix_plugin.dart'; import 'package:setonix_server/src/programs/whitelist.dart'; +import 'package:setonix_server/src/programs/worlds.dart'; import 'package:setonix_server/src/services/user/file.dart'; import 'package:setonix_server/src/services/user/remote.dart'; import 'package:path/path.dart' as p; @@ -66,9 +69,15 @@ final class SetonixServer { NetworkerSocketServer? _server; NetworkerPipe? _pipe; + Future? _closeFuture; + bool _closing = false; + + bool get isClosing => _closing; Set get channels => _server?.clientConnections ?? {}; + Uri? get address => _server?.address; + WorldBloc get defaultWorld => _worlds[defaultWorldName] ?? (_worlds[defaultWorldName] = WorldBloc( @@ -98,6 +107,8 @@ final class SetonixServer { WorldBloc? getWorld(String name) => _worlds[name]; + Iterable get worldNames => _worlds.keys; + String getUserWorldName(Channel channel) => _userWorlds[channel] ?? defaultWorldName; @@ -107,8 +118,16 @@ final class SetonixServer { WorldState? getUserWorldState(Channel channel) => getUserWorld(channel)?.state; - ServerState buildServerState(Channel viewer, WorldBloc world) { + ServerState buildServerState( + Channel viewer, + WorldBloc world, { + Iterable bannedUsers = const [], + }) { final viewerRoles = userManager.getUser(viewer)?.roles ?? const {}; + final permissions = permissionsForRoles( + viewerRoles, + configManager.serverRoles, + ); return ServerState( players: userManager .getUsers() @@ -128,8 +147,21 @@ final class SetonixServer { ), ) .toList(growable: false), + bannedUsers: permissions.contains(ServerPermission.banPlayers) + ? bannedUsers + .where((user) => user.fingerprint != null && user.isBanned) + .map( + (user) => BannedUserInfo( + id: user.fingerprint!, + name: user.name, + expiresAt: user.bannedUntil, + reason: user.banReason, + ), + ) + .toList(growable: false) + : const [], serverRoles: configManager.serverRoles, - permissions: permissionsForRoles(viewerRoles, configManager.serverRoles), + permissions: permissions, assignableServerRoles: configManager.serverRoles.keys .where( (role) => canAssignServerRole( @@ -143,20 +175,35 @@ final class SetonixServer { } Future broadcastServerState(WorldBloc world) async { + if (_closing) return; + final viewers = world.players + .where((channel) => userManager.getUser(channel) != null) + .toList(growable: false); + final needsBannedUsers = viewers.any( + (channel) => rolesAllowPermission( + userManager.getUser(channel)!.roles, + ServerPermission.banPlayers, + configManager.serverRoles, + ), + ); + final bannedUsers = needsBannedUsers + ? await userManager.getBannedUsers() + : const []; await Future.wait( - world.players - .where((channel) => userManager.getUser(channel) != null) - .map( - (channel) => sendEvent( - ServerStateUpdated(buildServerState(channel, world)), - target: channel, - worldName: world.worldName, - ), + viewers.map( + (channel) => sendEvent( + ServerStateUpdated( + buildServerState(channel, world, bannedUsers: bannedUsers), ), + target: channel, + worldName: world.worldName, + ), + ), ); } Future broadcastAllServerStates() async { + if (_closing) return; await Future.wait(_worlds.values.map(broadcastServerState)); } @@ -172,9 +219,10 @@ final class SetonixServer { static Future load({ String? worldFile, SetonixConfig argsConfig = const SetonixConfig(), + String? rootPath, }) async { - String rootDirectory = Directory.current.path; - if (Platform.script.scheme == 'file') { + String rootDirectory = rootPath ?? Directory.current.path; + if (rootPath == null && Platform.script.scheme == 'file') { final scriptDir = p.dirname(Platform.script.toFilePath()); if (p.basename(scriptDir) == 'bin') { rootDirectory = p.dirname(scriptDir); @@ -361,7 +409,10 @@ final class SetonixServer { 'kick': KickProgram(this), 'ban': BanProgram(this, banned: true), 'unban': BanProgram(this, banned: false), + 'bans': BansProgram(this), 'whitelist': WhitelistProgram(this), + 'worlds': WorldsProgram(this), + 'modes': ModesProgram(this), 'scripts': ScriptsProgram(this), null: UnknownProgram(), }); @@ -379,9 +430,12 @@ final class SetonixServer { NetworkerPacket packet, { bool force = false, String? worldName, - }) => getWorld( - worldName ?? defaultWorldName, - )?.onClientEvent(packet, force: force); + }) { + if (_closing) return; + getWorld( + worldName ?? defaultWorldName, + )?.onClientEvent(packet, force: force); + } static R _runStaticLogZone(Consoler consoler, R Function() body) => runZoned( @@ -421,6 +475,10 @@ final class SetonixServer { void _onJoin((Channel, ConnectionInfo) event) { final (user, info) = event; + if (_closing) { + info.close(); + return; + } final maxPlayers = configManager.maxPlayers; if (maxPlayers >= 0 && players.length > maxPlayers) { log( @@ -440,15 +498,19 @@ final class SetonixServer { final (user, info) = event; log('${info.address} ($user) left the game', level: LogLevel.info); final world = getUserWorld(user); - world?.eventSystem.runLeaveCallback(event.$1, event.$2); - if (world != null && world.state.getGameRoles(user).isNotEmpty) { - unawaited(sendEvent(GameRolesChanged(user), worldName: world.worldName)); + if (!_closing) { + world?.eventSystem.runLeaveCallback(event.$1, event.$2); + if (world != null && world.state.getGameRoles(user).isNotEmpty) { + unawaited( + sendEvent(GameRolesChanged(user), worldName: world.worldName), + ); + } } _userWorlds.remove(user); userManager.removeUser(user); challengeManager?.removeChallenge(user); - unawaited(broadcastAllServerStates()); + if (!_closing) unawaited(broadcastAllServerStates()); } Future loadWorlds() async { @@ -504,12 +566,32 @@ final class SetonixServer { await Future.wait(_worlds.values.map((e) => e.save(force: force))); } - Future close() async { + Future close() => _closeFuture ??= _close(); + + Future _close() async { + _closing = true; + log('Closing...', level: LogLevel.info); + final networkServer = _server; + _server = null; + final connections = networkServer?.clientConnections + .map(networkServer.getConnectionInfo) + .nonNulls + .toList(growable: false); + if (connections != null && connections.isNotEmpty) { + await Future.wait( + connections.map( + (info) => info.socket.close( + WebSocketStatus.goingAway, + 'Server is shutting down.', + ), + ), + ).timeout(const Duration(seconds: 2), onTimeout: () => const []); + } + await networkServer?.server?.close(force: true); + await networkServer?.close(); for (final world in _worlds.values) { await world.close(); } - log('Closing...', level: LogLevel.info); - _server?.close(); await userManager.service?.close(); consoler.dispose(); } diff --git a/server/lib/src/services/user/file.dart b/server/lib/src/services/user/file.dart index 466c73c6..63dd25e3 100644 --- a/server/lib/src/services/user/file.dart +++ b/server/lib/src/services/user/file.dart @@ -78,6 +78,15 @@ final class FileUserService extends UserService { .map(_fromRow) .firstOrNull; + @override + List getBannedUsers() => + _database + ?.select('SELECT * FROM users WHERE banned = 1 ORDER BY name') + .map(_fromRow) + .where((user) => user.isBanned) + .toList(growable: false) ?? + const []; + @override bool updateUser( String fingerprint, { diff --git a/server/test/authorization_test.dart b/server/test/authorization_test.dart index 3efc4ccb..2bb5961e 100644 --- a/server/test/authorization_test.dart +++ b/server/test/authorization_test.dart @@ -110,6 +110,9 @@ void main() { isFalse, ); expect(valid(BanPlayerRequest(3)), isTrue); + expect(valid(UnbanPlayerRequest('fingerprint')), isTrue); + expect(valid(UnbanPlayerRequest('')), isFalse); + expect(valid(UnbanPlayerRequest(text(257))), isFalse); expect( valid( BanPlayerRequest( @@ -180,6 +183,13 @@ void main() { ); }); + test('unbanning requires the ban permission', () { + final event = UnbanPlayerRequest('fingerprint'); + + expect(canProcessClientEvent({'moderator'}, event, roles), isTrue); + expect(canProcessClientEvent({'spectator'}, event, roles), isFalse); + }); + test('role hierarchy prevents managing peers and higher roles', () { expect( canManageServerRoles({'player', 'moderator'}, {'player'}, roles), diff --git a/server/test/event_system_test.dart b/server/test/event_system_test.dart new file mode 100644 index 00000000..3f024bc7 --- /dev/null +++ b/server/test/event_system_test.dart @@ -0,0 +1,31 @@ +import 'dart:typed_data'; + +import 'package:networker/networker.dart'; +import 'package:setonix_plugin/events.dart'; +import 'package:test/test.dart'; + +void main() { + test('late leave callbacks are ignored after event system disposal', () { + final system = EventSystem(); + final info = _TestConnectionInfo(); + + system.dispose(); + + expect(() => system.runLeaveCallback(2, info), returnsNormally); + expect(system.dispose, returnsNormally); + }); +} + +final class _TestConnectionInfo extends ConnectionInfo { + @override + Uri get address => Uri.parse('ws://localhost'); + + @override + bool get isClosed => false; + + @override + void close() {} + + @override + void sendMessage(Uint8List data) {} +} diff --git a/server/test/shutdown_test.dart b/server/test/shutdown_test.dart new file mode 100644 index 00000000..447f8c95 --- /dev/null +++ b/server/test/shutdown_test.dart @@ -0,0 +1,51 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:setonix_api/setonix_api.dart'; +import 'package:setonix_server/src/server.dart'; +import 'package:test/test.dart'; + +void main() { + test( + 'closing with a connected client does not emit into closed worlds', + () async { + final directory = await Directory.systemTemp.createTemp( + 'setonix-shutdown-', + ); + final server = await SetonixServer.load( + rootPath: directory.path, + argsConfig: const SetonixConfig( + host: '127.0.0.1', + port: 0, + accountRequired: false, + ), + ); + addTearDown(() async { + await server.close(); + await directory.delete(recursive: true); + }); + await server.init(); + expect( + server.consoler.programs.map((entry) => entry.key), + containsAll(['bans', 'worlds', 'modes']), + ); + final address = server.address!; + final socket = await WebSocket.connect( + addSetonixProtocolVersion( + Uri(scheme: 'ws', host: address.host, port: address.port), + ).toString(), + ); + final disconnected = Completer(); + socket.listen( + (_) {}, + onDone: disconnected.complete, + onError: disconnected.completeError, + ); + + await server.close().timeout(const Duration(seconds: 5)); + await disconnected.future.timeout(const Duration(seconds: 5)); + + expect(server.isClosing, isTrue); + }, + ); +} diff --git a/server/test/user_role_test.dart b/server/test/user_role_test.dart index b1d9eb81..cf44108f 100644 --- a/server/test/user_role_test.dart +++ b/server/test/user_role_test.dart @@ -92,4 +92,41 @@ void main() { expect(permanent.isBanned, isTrue); expect(expired.isBanned, isFalse); }); + + test('file user service lists active bans and supports unbanning', () async { + final directory = await Directory.systemTemp.createTemp( + 'setonix-user-bans-', + ); + final service = FileUserService(); + addTearDown(() async { + service.close(); + await directory.delete(recursive: true); + }); + await service.setup(rootPath: directory.path); + service.updateUser( + 'permanent', + name: 'Permanent', + banned: true, + banReason: 'Reason', + createIfNotExists: true, + ); + service.updateUser( + 'expired', + name: 'Expired', + banned: true, + bannedUntil: DateTime.now().subtract(const Duration(minutes: 1)), + createIfNotExists: true, + ); + + expect(service.getBannedUsers().map((user) => user.fingerprint), [ + 'permanent', + ]); + + final manager = UserManager(service: service); + expect(await manager.unban('permanent'), isTrue); + expect(service.getUser('permanent')?.banned, isFalse); + expect(service.getUser('permanent')?.bannedUntil, isNull); + expect(service.getUser('permanent')?.banReason, isNull); + expect(service.getBannedUsers(), isEmpty); + }); } From b75ecfc2fb27ae7497f0ef2bfde29426a1812b4d Mon Sep 17 00:00:00 2001 From: CodeDoctorDE Date: Fri, 14 Aug 2026 19:30:52 +0200 Subject: [PATCH 7/8] Add player name functionality --- api/lib/src/event/client.dart | 9 + api/lib/src/event/event.mapper.dart | 158 ++++++ api/lib/src/event/process/client.dart | 11 + api/lib/src/services/user.dart | 15 +- api/test/roles_test.dart | 22 + app/lib/pages/game/multiplayer/dialog.dart | 1 + app/lib/pages/game/multiplayer/view.dart | 586 +++++++++++---------- server/lib/setonix_server.dart | 1 + server/lib/src/authorization.dart | 3 +- server/lib/src/bloc.dart | 11 + server/lib/src/programs/name.dart | 33 ++ server/lib/src/server.dart | 2 + server/test/authorization_test.dart | 55 ++ 13 files changed, 638 insertions(+), 269 deletions(-) create mode 100644 server/lib/src/programs/name.dart diff --git a/api/lib/src/event/client.dart b/api/lib/src/event/client.dart index bf70db4e..eca94a58 100644 --- a/api/lib/src/event/client.dart +++ b/api/lib/src/event/client.dart @@ -227,6 +227,15 @@ final class UnbanPlayerRequest extends ClientWorldEvent UnbanPlayerRequest(this.userId); } +@MappableClass() +final class PlayerNameChangeRequest extends ClientWorldEvent + with PlayerNameChangeRequestMappable { + final Channel player; + final String name; + + PlayerNameChangeRequest(this.player, this.name); +} + @MappableClass() final class ServerRoleChangeRequest extends ClientWorldEvent with ServerRoleChangeRequestMappable { diff --git a/api/lib/src/event/event.mapper.dart b/api/lib/src/event/event.mapper.dart index 960f079a..b4983b7b 100644 --- a/api/lib/src/event/event.mapper.dart +++ b/api/lib/src/event/event.mapper.dart @@ -2618,6 +2618,7 @@ class ClientWorldEventMapper extends SubClassMapperBase { KickPlayerRequestMapper.ensureInitialized(); BanPlayerRequestMapper.ensureInitialized(); UnbanPlayerRequestMapper.ensureInitialized(); + PlayerNameChangeRequestMapper.ensureInitialized(); ServerRoleChangeRequestMapper.ensureInitialized(); GameRolesChangeRequestMapper.ensureInitialized(); HybridWorldEventMapper.ensureInitialized(); @@ -5239,6 +5240,163 @@ class _UnbanPlayerRequestCopyWithImpl<$R, $Out> ) => _UnbanPlayerRequestCopyWithImpl<$R2, $Out2>($value, $cast, t); } +/// @nodoc +class PlayerNameChangeRequestMapper + extends SubClassMapperBase { + PlayerNameChangeRequestMapper._(); + + static PlayerNameChangeRequestMapper? _instance; + static PlayerNameChangeRequestMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use( + _instance = PlayerNameChangeRequestMapper._(), + ); + ClientWorldEventMapper.ensureInitialized().addSubMapper(_instance!); + } + return _instance!; + } + + @override + final String id = 'PlayerNameChangeRequest'; + + static int _$player(PlayerNameChangeRequest v) => v.player; + static const Field _f$player = Field( + 'player', + _$player, + ); + static String _$name(PlayerNameChangeRequest v) => v.name; + static const Field _f$name = Field( + 'name', + _$name, + ); + + @override + final MappableFields fields = const { + #player: _f$player, + #name: _f$name, + }; + + @override + final String discriminatorKey = 'type'; + @override + final dynamic discriminatorValue = 'PlayerNameChangeRequest'; + @override + late final ClassMapperBase superMapper = + ClientWorldEventMapper.ensureInitialized(); + + static PlayerNameChangeRequest _instantiate(DecodingData data) { + return PlayerNameChangeRequest(data.dec(_f$player), data.dec(_f$name)); + } + + @override + final Function instantiate = _instantiate; + + static PlayerNameChangeRequest fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static PlayerNameChangeRequest fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +/// @nodoc +mixin PlayerNameChangeRequestMappable { + String toJson() { + return PlayerNameChangeRequestMapper.ensureInitialized() + .encodeJson(this as PlayerNameChangeRequest); + } + + Map toMap() { + return PlayerNameChangeRequestMapper.ensureInitialized() + .encodeMap(this as PlayerNameChangeRequest); + } + + PlayerNameChangeRequestCopyWith< + PlayerNameChangeRequest, + PlayerNameChangeRequest, + PlayerNameChangeRequest + > + get copyWith => + _PlayerNameChangeRequestCopyWithImpl< + PlayerNameChangeRequest, + PlayerNameChangeRequest + >(this as PlayerNameChangeRequest, $identity, $identity); + @override + String toString() { + return PlayerNameChangeRequestMapper.ensureInitialized().stringifyValue( + this as PlayerNameChangeRequest, + ); + } + + @override + bool operator ==(Object other) { + return PlayerNameChangeRequestMapper.ensureInitialized().equalsValue( + this as PlayerNameChangeRequest, + other, + ); + } + + @override + int get hashCode { + return PlayerNameChangeRequestMapper.ensureInitialized().hashValue( + this as PlayerNameChangeRequest, + ); + } +} + +/// @nodoc +extension PlayerNameChangeRequestValueCopy<$R, $Out> + on ObjectCopyWith<$R, PlayerNameChangeRequest, $Out> { + PlayerNameChangeRequestCopyWith<$R, PlayerNameChangeRequest, $Out> + get $asPlayerNameChangeRequest => $base.as( + (v, t, t2) => _PlayerNameChangeRequestCopyWithImpl<$R, $Out>(v, t, t2), + ); +} + +/// @nodoc +abstract class PlayerNameChangeRequestCopyWith< + $R, + $In extends PlayerNameChangeRequest, + $Out +> + implements ClientWorldEventCopyWith<$R, $In, $Out> { + @override + $R call({int? player, String? name}); + PlayerNameChangeRequestCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ); +} + +/// @nodoc +class _PlayerNameChangeRequestCopyWithImpl<$R, $Out> + extends ClassCopyWithBase<$R, PlayerNameChangeRequest, $Out> + implements + PlayerNameChangeRequestCopyWith<$R, PlayerNameChangeRequest, $Out> { + _PlayerNameChangeRequestCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = + PlayerNameChangeRequestMapper.ensureInitialized(); + @override + $R call({int? player, String? name}) => $apply( + FieldCopyWithData({ + if (player != null) #player: player, + if (name != null) #name: name, + }), + ); + @override + PlayerNameChangeRequest $make(CopyWithData data) => PlayerNameChangeRequest( + data.get(#player, or: $value.player), + data.get(#name, or: $value.name), + ); + + @override + PlayerNameChangeRequestCopyWith<$R2, PlayerNameChangeRequest, $Out2> + $chain<$R2, $Out2>(Then<$Out2, $R2> t) => + _PlayerNameChangeRequestCopyWithImpl<$R2, $Out2>($value, $cast, t); +} + /// @nodoc class ServerRoleChangeRequestMapper extends SubClassMapperBase { diff --git a/api/lib/src/event/process/client.dart b/api/lib/src/event/process/client.dart index 1c3a55c9..51396622 100644 --- a/api/lib/src/event/process/client.dart +++ b/api/lib/src/event/process/client.dart @@ -106,6 +106,12 @@ bool isValidClientEvent( UnbanPlayerRequest() => (channel == kAuthorityChannel || allowManagementRequests) && _isReasonableIdentifier(event.userId), + PlayerNameChangeRequest() => + (event.player == channel || + channel == kAuthorityChannel || + allowManagementRequests) && + event.name == event.name.trim() && + _isReasonableIdentifier(event.name), ServerRoleChangeRequest() => (channel == kAuthorityChannel || allowManagementRequests) && event.roles.length <= _maxRolesPerRequest && @@ -487,6 +493,11 @@ Future processClientEvent( ); } return UpdateServerResponse.builder(buildInitialize(), channel); + case PlayerNameChangeRequest(:final player, :final name): + if (userManager != null) { + await userManager.changeName(player, name); + } + return UpdateServerResponse.builder(null); case ToolbarActionRequest(): case KickPlayerRequest(): case BanPlayerRequest(): diff --git a/api/lib/src/services/user.dart b/api/lib/src/services/user.dart index d7910e1b..24d03db4 100644 --- a/api/lib/src/services/user.dart +++ b/api/lib/src/services/user.dart @@ -62,6 +62,7 @@ const kUserReferenceFingerprint = '*'; final class UserManager { final Map _users = {}; + final StreamController _changeController = StreamController.broadcast(); final String guestPrefix; final UserService? service; final bool whitelistEnabled; @@ -76,8 +77,12 @@ final class UserManager { bool containsUserName(String name) => _users.values.any((u) => u.name == name); + Stream get changes => _changeController.stream; + + void _notifyChanged() => _changeController.add(null); + void removeUser(Channel channel) { - _users.remove(channel); + if (_users.remove(channel) != null) _notifyChanged(); } /// Retrieves the user associated with the channel. @@ -143,17 +148,17 @@ final class UserManager { } } _users[channel] = user; + _notifyChanged(); return user; } Future changeName(Channel channel, String newName) async { - if (containsUserName(newName)) { - return false; - } final user = _users[channel]; if (user == null) { return false; } + if (user.name == newName) return true; + if (containsUserName(newName)) return false; final fingerprint = user.fingerprint; final result = fingerprint == null ? null @@ -161,6 +166,7 @@ final class UserManager { if (result == false) return false; final updatedUser = user.copyWith(name: newName); _users[channel] = updatedUser; + _notifyChanged(); return true; } @@ -180,6 +186,7 @@ final class UserManager { ); if (entry == null) return fingerprint != null; _users[entry.key] = entry.value.copyWith(roles: roles); + _notifyChanged(); return true; } diff --git a/api/test/roles_test.dart b/api/test/roles_test.dart index 528b6c24..209fcdfb 100644 --- a/api/test/roles_test.dart +++ b/api/test/roles_test.dart @@ -18,4 +18,26 @@ void main() { expect(request.effectiveRoles, {'player', 'moderator'}); }); + + test('player name change requests serialize their target and name', () { + final request = PlayerNameChangeRequest(2, 'Alex'); + + final decoded = WorldEventMapper.fromJson(request.toJson()); + + expect(decoded, isA()); + expect((decoded as PlayerNameChangeRequest).player, 2); + expect(decoded.name, 'Alex'); + }); + + test('user managers rename users and reject duplicate names', () async { + final manager = UserManager(); + final changes = manager.changes.take(3).toList(); + + await manager.addUser(1, null, 'Guest 1'); + await manager.addUser(2, null, 'Guest 2'); + expect(await manager.changeName(1, 'Alex'), isTrue); + expect(await manager.changeName(2, 'Alex'), isFalse); + expect(manager.getUser(1)?.name, 'Alex'); + expect(await changes, hasLength(3)); + }); } diff --git a/app/lib/pages/game/multiplayer/dialog.dart b/app/lib/pages/game/multiplayer/dialog.dart index b66370f3..76464834 100644 --- a/app/lib/pages/game/multiplayer/dialog.dart +++ b/app/lib/pages/game/multiplayer/dialog.dart @@ -16,6 +16,7 @@ import 'package:setonix/bloc/world/state.dart'; import 'package:setonix/bloc/settings.dart'; import 'package:setonix/src/generated/i18n/app_localizations.dart'; import 'package:setonix_api/setonix_api.dart'; +import 'package:rxdart/rxdart.dart'; part 'start.dart'; part 'view.dart'; diff --git a/app/lib/pages/game/multiplayer/view.dart b/app/lib/pages/game/multiplayer/view.dart index cb46ac38..ad136159 100644 --- a/app/lib/pages/game/multiplayer/view.dart +++ b/app/lib/pages/game/multiplayer/view.dart @@ -158,6 +158,45 @@ class ViewMultiplayerDialog extends StatelessWidget { ); } + Future _changePlayerName( + BuildContext context, + PlayerInfo player, + ) async { + final controller = TextEditingController(text: player.name ?? ''); + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context).rename), + content: TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + labelText: AppLocalizations.of(context).name, + ), + onSubmitted: (_) => Navigator.pop(context, true), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(MaterialLocalizations.of(context).saveButtonLabel), + ), + ], + ), + ); + final name = controller.text.trim(); + controller.dispose(); + if (confirmed != true || name.isEmpty || !context.mounted) return; + unawaited( + context.read().process( + PlayerNameChangeRequest(player.id, name), + ), + ); + } + Future _unbanUser(BuildContext context, BannedUserInfo user) async { final confirmed = await showDialog( context: context, @@ -191,6 +230,7 @@ class ViewMultiplayerDialog extends StatelessWidget { @override Widget build(BuildContext context) { + final multiplayer = context.read(); final info = FutureBuilder( future: Future.value(state.getShareAddress()), builder: (context, snapshot) { @@ -246,285 +286,303 @@ class ViewMultiplayerDialog extends StatelessWidget { }, ); final userList = StreamBuilder>( - stream: state.clientChange, - initialData: context.read().clients, - builder: (context, connectionSnapshot) => - BlocBuilder( - buildWhen: (previous, current) => - previous.world.serverState != current.world.serverState || - previous.world.gameRoleMembers != current.world.gameRoleMembers, - builder: (context, worldState) { - final serverState = worldState.world.serverState; - final players = serverState.players.isNotEmpty - ? serverState.players - : state.isServer - ? (connectionSnapshot.data ?? const {}) - .map( - (channel) => PlayerInfo( - id: channel, - name: state.userManager.getUser(channel)?.name, - ), - ) - .toList(growable: false) - : const []; - final canViewBans = serverState.permissions.contains( - ServerPermission.banPlayers, - ); - final playerList = players.isEmpty - ? Padding( - padding: const EdgeInsets.all(16), - child: Text( - AppLocalizations.of(context).noConnections, - textAlign: TextAlign.center, + stream: Rx.merge([ + ?state.clientChange, + state.userManager.changes.map((_) => multiplayer.clients), + ]), + initialData: multiplayer.clients, + builder: (context, connectionSnapshot) => BlocBuilder( + buildWhen: (previous, current) => + previous.world.serverState != current.world.serverState || + previous.world.gameRoleMembers != current.world.gameRoleMembers, + builder: (context, worldState) { + final serverState = worldState.world.serverState; + final players = state.isServer + ? (connectionSnapshot.data ?? const {}) + .map( + (channel) => PlayerInfo( + id: channel, + name: state.userManager.getUser(channel)?.name, + serverRoles: + state.userManager.getUser(channel)?.roles ?? + const {kDefaultServerRole}, + manageable: true, ), ) - : ListView.builder( - itemCount: players.length, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) { - final player = players[index]; - final gameRoles = worldState.world.getGameRoles( - player.id, + .toList(growable: false) + : serverState.players; + final canViewBans = serverState.permissions.contains( + ServerPermission.banPlayers, + ); + final playerList = players.isEmpty + ? Padding( + padding: const EdgeInsets.all(16), + child: Text( + AppLocalizations.of(context).noConnections, + textAlign: TextAlign.center, + ), + ) + : ListView.builder( + itemCount: players.length, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + final player = players[index]; + final gameRoles = worldState.world.getGameRoles(player.id); + final roleNames = player.serverRoles + .map( + (role) => worldState.assetManager + .getRoleTranslation( + role, + fallback: serverState.serverRoles[role]?.name, + ) + .name, + ) + .toList(growable: false); + final gameRoleNames = gameRoles + .map( + (role) => worldState.assetManager + .getRoleTranslation(role) + .name, + ) + .toList(growable: false); + final canKick = + player.manageable && + serverState.permissions.contains( + ServerPermission.kickPlayers, ); - final roleNames = player.serverRoles - .map( - (role) => worldState.assetManager - .getRoleTranslation( - role, - fallback: - serverState.serverRoles[role]?.name, - ) - .name, - ) - .toList(growable: false); - final gameRoleNames = gameRoles - .map( - (role) => worldState.assetManager - .getRoleTranslation(role) - .name, - ) - .toList(growable: false); - final canKick = - player.manageable && - serverState.permissions.contains( - ServerPermission.kickPlayers, - ); - final canBan = - player.registered && - player.manageable && - serverState.permissions.contains( - ServerPermission.banPlayers, - ); - final canManageRoles = - player.manageable && - serverState.permissions.contains( - ServerPermission.manageRoles, - ); - final canManageGameRoles = serverState.permissions - .contains(ServerPermission.manageWorld); - final canManage = - canKick || - canBan || - canManageRoles || - canManageGameRoles; - return ListTile( - title: Text( - player.name ?? - AppLocalizations.of( - context, - ).defaultUserName(player.id), - ), - subtitle: Text( - [ - roleNames.join(', '), - if (gameRoleNames.isNotEmpty) - gameRoleNames.join(', '), - ].join(' · '), - ), - trailing: canManage - ? MenuAnchor( - builder: defaultMenuButton( - tooltip: MaterialLocalizations.of( + final canBan = + player.registered && + player.manageable && + serverState.permissions.contains( + ServerPermission.banPlayers, + ); + final canManageRoles = + player.manageable && + serverState.permissions.contains( + ServerPermission.manageRoles, + ); + final canManageGameRoles = serverState.permissions.contains( + ServerPermission.manageWorld, + ); + final isCurrentUser = player.id == worldState.world.id; + final canRename = + isCurrentUser || + (player.manageable && + (state.isServer || canManageGameRoles)); + final hasModerationActions = + canKick || + canBan || + canManageRoles || + canManageGameRoles; + return ListTile( + title: Text( + player.name ?? + AppLocalizations.of( + context, + ).defaultUserName(player.id), + ), + subtitle: Text( + [ + if (isCurrentUser) AppLocalizations.of(context).you, + roleNames.join(', '), + if (gameRoleNames.isNotEmpty) + gameRoleNames.join(', '), + ].join(' · '), + ), + trailing: canRename || hasModerationActions + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (canRename) + IconButton( + tooltip: AppLocalizations.of( context, - ).showMenuTooltip, + ).rename, + icon: const PhosphorIcon( + PhosphorIconsLight.pencilSimple, + ), + onPressed: () => + _changePlayerName(context, player), ), - menuChildren: [ - if (canKick) - MenuItemButton( - leadingIcon: const PhosphorIcon( - PhosphorIconsLight.userMinus, - ), - onPressed: () => _moderatePlayer( - context, - player, - ban: false, - ), - child: Text( - AppLocalizations.of(context).kick, - ), - ), - if (canBan) - MenuItemButton( - leadingIcon: const PhosphorIcon( - PhosphorIconsLight.gavel, - ), - onPressed: () => _moderatePlayer( - context, - player, - ban: true, - ), - child: Text( - AppLocalizations.of(context).ban, - ), - ), - if (canManageGameRoles) - MenuItemButton( - leadingIcon: const PhosphorIcon( - PhosphorIconsLight.gameController, - ), - onPressed: () => _changeGameRoles( - context, - player, - gameRoles, + if (hasModerationActions) + MenuAnchor( + builder: defaultMenuButton( + tooltip: MaterialLocalizations.of( + context, + ).showMenuTooltip, + ), + menuChildren: [ + if (canKick) + MenuItemButton( + leadingIcon: const PhosphorIcon( + PhosphorIconsLight.userMinus, + ), + onPressed: () => _moderatePlayer( + context, + player, + ban: false, + ), + child: Text( + AppLocalizations.of(context).kick, + ), ), - child: Text( - AppLocalizations.of( + if (canBan) + MenuItemButton( + leadingIcon: const PhosphorIcon( + PhosphorIconsLight.gavel, + ), + onPressed: () => _moderatePlayer( context, - ).gameRoles, + player, + ban: true, + ), + child: Text( + AppLocalizations.of(context).ban, + ), ), - ), - if (canManageRoles) - SubmenuButton( - leadingIcon: const PhosphorIcon( - PhosphorIconsLight - .identificationBadge, + if (canManageGameRoles) + MenuItemButton( + leadingIcon: const PhosphorIcon( + PhosphorIconsLight.gameController, + ), + onPressed: () => _changeGameRoles( + context, + player, + gameRoles, + ), + child: Text( + AppLocalizations.of( + context, + ).gameRoles, + ), ), - menuChildren: serverState - .serverRoles - .entries - .where( - (entry) => serverState - .assignableServerRoles - .contains(entry.key), - ) - .map((entry) { - final translatedRole = worldState - .assetManager - .getRoleTranslation( - entry.key, - fallback: entry.value.name, - ) - .name; - final translatedPermissions = entry - .value - .permissions - .map( - (permission) => worldState - .assetManager - .getPermissionTranslation( - permission, - ) - .name, - ) - .join(', '); - return CheckboxMenuButton( - value: player.serverRoles + if (canManageRoles) + SubmenuButton( + leadingIcon: const PhosphorIcon( + PhosphorIconsLight + .identificationBadge, + ), + menuChildren: serverState + .serverRoles + .entries + .where( + (entry) => serverState + .assignableServerRoles .contains(entry.key), - closeOnActivate: false, - onChanged: - entry.key == - kDefaultServerRole - ? null - : (_) => _toggleServerRole( - context, - player, - entry.key, - ), - child: ListTile( - contentPadding: - EdgeInsets.zero, - dense: true, - title: Text(translatedRole), - subtitle: - translatedPermissions - .isEmpty + ) + .map((entry) { + final translatedRole = + worldState.assetManager + .getRoleTranslation( + entry.key, + fallback: + entry.value.name, + ) + .name; + final translatedPermissions = entry + .value + .permissions + .map( + (permission) => worldState + .assetManager + .getPermissionTranslation( + permission, + ) + .name, + ) + .join(', '); + return CheckboxMenuButton( + value: player.serverRoles + .contains(entry.key), + closeOnActivate: false, + onChanged: + entry.key == + kDefaultServerRole ? null - : Text( - translatedPermissions, - ), - ), - ); - }) - .toList(growable: false), - child: Text( - AppLocalizations.of( - context, - ).serverRoles, + : (_) => + _toggleServerRole( + context, + player, + entry.key, + ), + child: ListTile( + contentPadding: + EdgeInsets.zero, + dense: true, + title: Text(translatedRole), + subtitle: + translatedPermissions + .isEmpty + ? null + : Text( + translatedPermissions, + ), + ), + ); + }) + .toList(growable: false), + child: Text( + AppLocalizations.of( + context, + ).serverRoles, + ), ), - ), - ], - ) - : null, - ); - }, + ], + ), + ], + ) + : null, ); - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - playerList, - if (canViewBans) ...[ - const Divider(), - ExpansionTile( - leading: const PhosphorIcon(PhosphorIconsLight.gavel), - title: Text(AppLocalizations.of(context).bannedUsers), - initiallyExpanded: serverState.bannedUsers.isNotEmpty, - children: serverState.bannedUsers.isEmpty - ? [ - ListTile( - title: Text( - AppLocalizations.of(context).noBannedUsers, + }, + ); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + playerList, + if (canViewBans) ...[ + const Divider(), + ExpansionTile( + leading: const PhosphorIcon(PhosphorIconsLight.gavel), + title: Text(AppLocalizations.of(context).bannedUsers), + initiallyExpanded: serverState.bannedUsers.isNotEmpty, + children: serverState.bannedUsers.isEmpty + ? [ + ListTile( + title: Text( + AppLocalizations.of(context).noBannedUsers, + ), + ), + ] + : serverState.bannedUsers + .map( + (user) => ListTile( + title: Text(user.name), + subtitle: Text( + [ + ?user.reason, + if (user.expiresAt case final expiresAt?) + _formatBanExpiration(context, expiresAt) + else + AppLocalizations.of(context).permanent, + ].join('\n'), ), - ), - ] - : serverState.bannedUsers - .map( - (user) => ListTile( - title: Text(user.name), - subtitle: Text( - [ - ?user.reason, - if (user.expiresAt - case final expiresAt?) - _formatBanExpiration( - context, - expiresAt, - ) - else - AppLocalizations.of( - context, - ).permanent, - ].join('\n'), - ), - trailing: IconButton.filledTonal( - icon: const PhosphorIcon( - PhosphorIconsLight.lockOpen, - ), - tooltip: AppLocalizations.of( - context, - ).unban, - onPressed: () => - _unbanUser(context, user), - ), + trailing: IconButton.filledTonal( + icon: const PhosphorIcon( + PhosphorIconsLight.lockOpen, ), - ) - .toList(growable: false), - ), - ], - ], - ); - }, - ), + tooltip: AppLocalizations.of(context).unban, + onPressed: () => _unbanUser(context, user), + ), + ), + ) + .toList(growable: false), + ), + ], + ], + ); + }, + ), ); final size = MediaQuery.sizeOf(context); final isMobile = size.width < LeapBreakpoints.medium; diff --git a/server/lib/setonix_server.dart b/server/lib/setonix_server.dart index fd636a1d..da5f62af 100644 --- a/server/lib/setonix_server.dart +++ b/server/lib/setonix_server.dart @@ -12,6 +12,7 @@ export 'src/programs/kick.dart'; export 'src/programs/ban.dart'; export 'src/programs/bans.dart'; export 'src/programs/modes.dart'; +export 'src/programs/name.dart'; export 'src/programs/packs.dart'; export 'src/programs/players.dart'; export 'src/programs/role.dart'; diff --git a/server/lib/src/authorization.dart b/server/lib/src/authorization.dart index fd89061c..ecc27792 100644 --- a/server/lib/src/authorization.dart +++ b/server/lib/src/authorization.dart @@ -4,7 +4,8 @@ String? requiredPermission(WorldEvent event) => switch (event) { AuthenticateRequest() || MessageRequest() || ImagesRequest() || - DialogCloseRequest() => null, + DialogCloseRequest() || + PlayerNameChangeRequest() => null, PacksChangeRequest() || ModeChangeRequest() || MetadataChanged() || diff --git a/server/lib/src/bloc.dart b/server/lib/src/bloc.dart index fae7ab0a..721f79c2 100644 --- a/server/lib/src/bloc.dart +++ b/server/lib/src/bloc.dart @@ -325,6 +325,7 @@ class WorldBloc extends Bloc final targetId = switch (event) { KickPlayerRequest() => event.player, BanPlayerRequest() => event.player, + PlayerNameChangeRequest() => event.player, ServerRoleChangeRequest() => event.player, GameRolesChangeRequest() => event.player, _ => null, @@ -366,6 +367,16 @@ class WorldBloc extends Bloc KickMessage(reason: KickReason.ban, message: event.reason), ); } + case PlayerNameChangeRequest(): + if (actor == null || + !targetIsInWorld || + (actorId != event.player && + !canManageServerRoles(actor.roles, target.roles, roles))) { + return true; + } + if (await server.userManager.changeName(event.player, event.name)) { + await server.broadcastAllServerStates(); + } case ServerRoleChangeRequest(): final requestedRoles = event.effectiveRoles; if (actor == null || diff --git a/server/lib/src/programs/name.dart b/server/lib/src/programs/name.dart new file mode 100644 index 00000000..34a6fc90 --- /dev/null +++ b/server/lib/src/programs/name.dart @@ -0,0 +1,33 @@ +import 'package:consoler/consoler.dart'; +import 'package:setonix_server/src/programs/async.dart'; + +class NameProgram extends AsyncServerProgram { + NameProgram(super.server); + + @override + String getUsage() => ' '; + + @override + String getDescription() => 'Change a connected user\'s display name.'; + + @override + Future runAsync(String label, List args) async { + if (args.length < 2) { + server.log('Wrong usage, use ${getUsage()}', level: LogLevel.error); + return; + } + final reference = args.first; + final name = args.skip(1).join(' ').trim(); + final channel = await server.userManager.getUserIdByReference(reference); + if (channel == null || name.isEmpty || name.length > 256) { + server.log('User not found or invalid name.', level: LogLevel.error); + return; + } + if (!await server.userManager.changeName(channel, name)) { + server.log('The name is already in use.', level: LogLevel.error); + return; + } + await server.broadcastAllServerStates(); + server.log('Changed $reference name to $name', level: LogLevel.info); + } +} diff --git a/server/lib/src/server.dart b/server/lib/src/server.dart index d53546c4..99101e16 100644 --- a/server/lib/src/server.dart +++ b/server/lib/src/server.dart @@ -14,6 +14,7 @@ import 'package:setonix_server/src/programs/kick.dart'; import 'package:setonix_server/src/programs/ban.dart'; import 'package:setonix_server/src/programs/bans.dart'; import 'package:setonix_server/src/programs/modes.dart'; +import 'package:setonix_server/src/programs/name.dart'; import 'package:setonix_server/src/programs/packs.dart'; import 'package:setonix_server/src/programs/players.dart'; import 'package:setonix_server/src/programs/reset.dart'; @@ -413,6 +414,7 @@ final class SetonixServer { 'whitelist': WhitelistProgram(this), 'worlds': WorldsProgram(this), 'modes': ModesProgram(this), + 'name': NameProgram(this), 'scripts': ScriptsProgram(this), null: UnknownProgram(), }); diff --git a/server/test/authorization_test.dart b/server/test/authorization_test.dart index 2bb5961e..d84ee5bb 100644 --- a/server/test/authorization_test.dart +++ b/server/test/authorization_test.dart @@ -77,6 +77,49 @@ void main() { expect(response.main?.data, isA()); }); + test('peer-host authority can rename connected players', () async { + final state = WorldState(data: SetonixData.empty()); + final users = UserManager(); + await users.addUser(2, null, 'Guest 1'); + + await processClientEvent( + PlayerNameChangeRequest(2, 'Alex'), + kAuthorityChannel, + state, + assetManager: _EmptyAssetManager(), + userManager: users, + ); + + expect(users.getUser(2)?.name, 'Alex'); + }); + + test('players can rename themselves but not another player', () async { + final state = WorldState(data: SetonixData.empty()); + final users = UserManager(); + await users.addUser(2, null, 'Guest 1'); + await users.addUser(3, null, 'Guest 2'); + + final selfResponse = await processClientEvent( + PlayerNameChangeRequest(2, 'Alex'), + 2, + state, + assetManager: _EmptyAssetManager(), + userManager: users, + ); + final otherResponse = await processClientEvent( + PlayerNameChangeRequest(3, 'Mallory'), + 2, + state, + assetManager: _EmptyAssetManager(), + userManager: users, + ); + + expect(selfResponse, isA()); + expect(otherResponse, isNull); + expect(users.getUser(2)?.name, 'Alex'); + expect(users.getUser(3)?.name, 'Guest 2'); + }); + test('management payloads enforce text, role, and expiry limits', () { final state = WorldState(data: SetonixData.empty()); final assets = _EmptyAssetManager(); @@ -113,6 +156,10 @@ void main() { expect(valid(UnbanPlayerRequest('fingerprint')), isTrue); expect(valid(UnbanPlayerRequest('')), isFalse); expect(valid(UnbanPlayerRequest(text(257))), isFalse); + expect(valid(PlayerNameChangeRequest(3, 'Named player')), isTrue); + expect(valid(PlayerNameChangeRequest(3, '')), isFalse); + expect(valid(PlayerNameChangeRequest(3, ' padded ')), isFalse); + expect(valid(PlayerNameChangeRequest(3, text(257))), isFalse); expect( valid( BanPlayerRequest( @@ -190,6 +237,14 @@ void main() { expect(canProcessClientEvent({'spectator'}, event, roles), isFalse); }); + test('renaming yourself does not require a management permission', () { + final event = PlayerNameChangeRequest(2, 'Alex'); + + expect(canProcessClientEvent({'moderator'}, event, roles), isTrue); + expect(canProcessClientEvent({'owner'}, event, roles), isTrue); + expect(canProcessClientEvent({'player'}, event, roles), isTrue); + }); + test('role hierarchy prevents managing peers and higher roles', () { expect( canManageServerRoles({'player', 'moderator'}, {'player'}, roles), From 7a0290bae17dde5bd690286704778a48678c37fb Mon Sep 17 00:00:00 2001 From: CodeDoctorDE Date: Fri, 14 Aug 2026 19:32:37 +0200 Subject: [PATCH 8/8] Upgrade to flutter 3.47 --- .github/workflows/build.yml | 2 +- api/analysis_options.yaml | 9 ++++++ api/lib/src/models/background.dart | 1 + api/lib/src/models/data.dart | 1 + api/lib/src/models/deck.dart | 1 + api/lib/src/models/definition.dart | 1 + api/lib/src/models/table.dart | 1 + api/lib/src/services/asset.dart | 17 +++++------ api/pubspec.lock | 2 +- api/pubspec.yaml | 2 +- app/lib/board/game.dart | 5 ++- app/lib/board/hand/deck.dart | 8 +++-- app/lib/board/hand/figure.dart | 5 ++- app/lib/board/hand/object.dart | 5 ++- app/lib/board/hand/tile.dart | 5 ++- app/lib/board/hand/view.dart | 6 ++-- app/lib/helpers/asset.dart | 10 +++--- app/lib/main.dart | 7 +++-- app/lib/pages/editor/textures.dart | 5 ++- app/lib/pages/game/auth.dart | 5 ++- app/lib/pages/game/chat.dart | 10 +++--- app/lib/pages/game/dialog.dart | 11 +++---- app/lib/pages/game/drawer.dart | 10 +++--- app/lib/pages/game/multiplayer/start.dart | 10 +++--- app/lib/pages/game/multiplayer/view.dart | 25 ++++++--------- app/lib/pages/game/notes.dart | 5 ++- app/lib/pages/game/page.dart | 34 ++++++++++----------- app/lib/pages/home/connect.dart | 26 +++++++--------- app/lib/pages/home/create.dart | 6 ++-- app/lib/pages/home/page.dart | 5 ++- app/lib/pages/home/play.dart | 5 ++- app/lib/pages/packs/dialog.dart | 13 ++++---- app/lib/pages/packs/editor.dart | 5 ++- app/lib/pages/settings/data.dart | 5 ++- app/lib/pages/settings/general.dart | 20 +++++------- app/lib/pages/settings/personalization.dart | 4 +-- app/lib/pages/settings/servers.dart | 5 ++- app/lib/services/file_system.dart | 10 +++--- app/pubspec.lock | 4 +-- app/pubspec.yaml | 4 +-- plugin/analysis_options.yaml | 7 +++++ plugin/lib/src/events/system.dart | 5 +-- plugin/lib/src/plugin.dart | 9 +++--- plugin/lib/src/rust/api/luau.dart | 2 ++ plugin/lib/src/rust/api/plugin.dart | 1 + plugin/lib/src/rust/api/simple.dart | 1 + plugin/lib/src/rust/frb_generated.dart | 3 ++ plugin/lib/src/rust/frb_generated.io.dart | 3 ++ plugin/lib/src/rust/frb_generated.web.dart | 3 ++ plugin/lib/src/rust/lib.dart | 1 + plugin/pubspec.lock | 2 +- plugin/pubspec.yaml | 2 +- plugin/rust/src/frb_generated.rs | 6 ++-- server/analysis_options.yaml | 9 ++++++ server/lib/setonix_server.dart | 2 ++ server/lib/src/main.dart | 3 +- server/lib/src/server.dart | 15 ++++----- server/pubspec.lock | 2 +- server/pubspec.yaml | 2 +- tools/analysis_options.yaml | 9 ++++++ tools/pubspec.lock | 2 +- tools/pubspec.yaml | 2 +- 62 files changed, 209 insertions(+), 197 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 48083b26..fd841628 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -617,7 +617,7 @@ jobs: cargo build --release - name: Compile run: | - dart build cli bin/setonix_server.dart + dart build cli --target=bin/setonix_server.dart - name: Create directory run: | mkdir -p server-build diff --git a/api/analysis_options.yaml b/api/analysis_options.yaml index dee8927a..6c4f4c8d 100644 --- a/api/analysis_options.yaml +++ b/api/analysis_options.yaml @@ -11,6 +11,15 @@ # (the recommended set includes the core lints). # The core lints are also what is used by pub.dev for scoring packages. +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:lints/recommended.yaml # Uncomment the following section to specify additional rules. diff --git a/api/lib/src/models/background.dart b/api/lib/src/models/background.dart index 3d097f98..a76c6870 100644 --- a/api/lib/src/models/background.dart +++ b/api/lib/src/models/background.dart @@ -1,4 +1,5 @@ import 'package:dart_mappable/dart_mappable.dart'; + import 'vector.dart'; import 'visual.dart'; diff --git a/api/lib/src/models/data.dart b/api/lib/src/models/data.dart index 29066eeb..512987aa 100644 --- a/api/lib/src/models/data.dart +++ b/api/lib/src/models/data.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:crypto/crypto.dart'; import 'package:lw_file_system_api/lw_file_system_api.dart'; + import 'background.dart'; import 'deck.dart'; import 'info.dart'; diff --git a/api/lib/src/models/deck.dart b/api/lib/src/models/deck.dart index 035d7ace..47678090 100644 --- a/api/lib/src/models/deck.dart +++ b/api/lib/src/models/deck.dart @@ -1,4 +1,5 @@ import 'package:dart_mappable/dart_mappable.dart'; + import 'vector.dart'; part 'deck.mapper.dart'; diff --git a/api/lib/src/models/definition.dart b/api/lib/src/models/definition.dart index dc5dfba1..eded64dd 100644 --- a/api/lib/src/models/definition.dart +++ b/api/lib/src/models/definition.dart @@ -1,4 +1,5 @@ import 'package:dart_mappable/dart_mappable.dart'; + import 'vector.dart'; import 'visual.dart'; diff --git a/api/lib/src/models/table.dart b/api/lib/src/models/table.dart index 37f0295c..0face411 100644 --- a/api/lib/src/models/table.dart +++ b/api/lib/src/models/table.dart @@ -1,4 +1,5 @@ import 'package:dart_mappable/dart_mappable.dart'; + import '../helpers/equality.dart'; import 'cell.dart'; import 'vector.dart'; diff --git a/api/lib/src/services/asset.dart b/api/lib/src/services/asset.dart index 1332ab1d..cf9a405a 100644 --- a/api/lib/src/services/asset.dart +++ b/api/lib/src/services/asset.dart @@ -29,24 +29,23 @@ abstract class AssetManager { BoardDefinition? getBoard(ItemLocation location) => getPack(location.namespace)?.getBoard(location.id); - PackItem? getBoardItem(ItemLocation location) => getPack( - location.namespace, - )?.getBoardItem(location.id, location.namespace); + PackItem? getBoardItem(ItemLocation location) => + getPack(location.namespace) + ?.getBoardItem(location.id, location.namespace); FigureDefinition? getFigure(ItemLocation location) => getPack(location.namespace)?.getFigure(location.id); - PackItem? getFigureItem(ItemLocation location) => getPack( - location.namespace, - )?.getFigureItem(location.id, location.namespace); + PackItem? getFigureItem(ItemLocation location) => + getPack(location.namespace) + ?.getFigureItem(location.id, location.namespace); BackgroundDefinition? getBackground(ItemLocation location) => getPack(location.namespace)?.getBackground(location.id); PackItem? getBackgroundItem(ItemLocation location) => - getPack( - location.namespace, - )?.getBackgroundItem(location.id, location.namespace); + getPack(location.namespace) + ?.getBackgroundItem(location.id, location.namespace); DeckDefinition? getDeck(ItemLocation location) => getPack(location.namespace)?.getDeck(location.id); diff --git a/api/pubspec.lock b/api/pubspec.lock index 2afc083c..138dce4c 100644 --- a/api/pubspec.lock +++ b/api/pubspec.lock @@ -589,4 +589,4 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.11.0 <4.0.0" + dart: ">=3.13.0 <4.0.0" diff --git a/api/pubspec.yaml b/api/pubspec.yaml index a36f11f7..7ec852af 100644 --- a/api/pubspec.yaml +++ b/api/pubspec.yaml @@ -5,7 +5,7 @@ publish_to: none # repository: https://github.com/my_org/my_repo environment: - sdk: ">=3.8.0 <4.0.0" + sdk: ">=3.13.0 <4.0.0" # Add regular dependencies here. dependencies: diff --git a/app/lib/board/game.dart b/app/lib/board/game.dart index c7ab0e1e..4e58dee0 100644 --- a/app/lib/board/game.dart +++ b/app/lib/board/game.dart @@ -77,9 +77,8 @@ class BoardGame extends FlameGame void _updateLocale() { final context = buildContext; if (context == null) return; - bloc.state.assetManager.currentLocale = Localizations.localeOf( - context, - ).languageCode; + bloc.state.assetManager.currentLocale = Localizations.localeOf(context) + .languageCode; } static const zoomPerScrollUnit = 0.02; diff --git a/app/lib/board/hand/deck.dart b/app/lib/board/hand/deck.dart index f63de00e..1b22d325 100644 --- a/app/lib/board/hand/deck.dart +++ b/app/lib/board/hand/deck.dart @@ -10,9 +10,11 @@ class DeckDefinitionHandItem extends HandItem> { DeckDefinitionHandItem({required super.item}); @override - String getLabel(ClientWorldState state) => getAssetManager( - state, - ).getTranslations(item.namespace).getDeckTranslation(item.id).name; + String getLabel(ClientWorldState state) => + getAssetManager(state) + .getTranslations(item.namespace) + .getDeckTranslation(item.id) + .name; bool matches(ClientWorldState state, String term) => item.location.toString().containsIgnoreCase(term) || diff --git a/app/lib/board/hand/figure.dart b/app/lib/board/hand/figure.dart index 390c2e69..f5af4c7e 100644 --- a/app/lib/board/hand/figure.dart +++ b/app/lib/board/hand/figure.dart @@ -11,9 +11,8 @@ class FigureDefinitionHandItem @override String getLabel(ClientWorldState state) { - final translation = getAssetManager( - state, - ).getTranslations(item.$1.namespace); + final translation = getAssetManager(state) + .getTranslations(item.$1.namespace); final variation = item.$2; if (variation != null) { return translation diff --git a/app/lib/board/hand/object.dart b/app/lib/board/hand/object.dart index 003aef47..90cabf36 100644 --- a/app/lib/board/hand/object.dart +++ b/app/lib/board/hand/object.dart @@ -16,9 +16,8 @@ class GameObjectHandItem extends HandItem<(VectorDefinition, int, GameObject)> { @override String getLabel(ClientWorldState state) { final object = item.$3; - final translation = getAssetManager( - state, - ).getTranslations(object.asset.namespace); + final translation = getAssetManager(state) + .getTranslations(object.asset.namespace); final variation = object.variation; if (variation != null && !item.$3.hidden && diff --git a/app/lib/board/hand/tile.dart b/app/lib/board/hand/tile.dart index b1900a59..04b5c034 100644 --- a/app/lib/board/hand/tile.dart +++ b/app/lib/board/hand/tile.dart @@ -12,9 +12,8 @@ class BoardTileHandItem extends HandItem<(VectorDefinition, int, BoardTile)> { @override String getLabel(ClientWorldState state) { final object = item.$3; - final translation = getAssetManager( - state, - ).getTranslations(object.asset.namespace); + final translation = getAssetManager(state) + .getTranslations(object.asset.namespace); return translation.getBoardTranslation(object.asset.id).name; } diff --git a/app/lib/board/hand/view.dart b/app/lib/board/hand/view.dart index 34704c21..e112c7be 100644 --- a/app/lib/board/hand/view.dart +++ b/app/lib/board/hand/view.dart @@ -278,9 +278,9 @@ class GameHand extends CustomPainterComponent event.continuePropagation = true; return; } - final isOverItem = componentsAtPoint( - event.localPosition, - ).whereType().isNotEmpty; + final isOverItem = componentsAtPoint(event.localPosition) + .whereType() + .isNotEmpty; if (isOverItem) { event.continuePropagation = true; } diff --git a/app/lib/helpers/asset.dart b/app/lib/helpers/asset.dart index d23263d9..bc1ae021 100644 --- a/app/lib/helpers/asset.dart +++ b/app/lib/helpers/asset.dart @@ -120,9 +120,8 @@ class GameAssetManager extends AssetManager { RoleTranslation getRoleTranslation(String key, {String? fallback}) { final location = ItemLocation.fromString(key); - return getTranslations( - location.namespace, - ).getRoleTranslation(location.id, fallback: fallback); + return getTranslations(location.namespace) + .getRoleTranslation(location.id, fallback: fallback); } PermissionTranslation getPermissionTranslation( @@ -130,9 +129,8 @@ class GameAssetManager extends AssetManager { String? fallback, }) { final location = ItemLocation.fromString(permission); - return getTranslations( - location.namespace, - ).getPermissionTranslation(location.id, fallback: fallback); + return getTranslations(location.namespace) + .getPermissionTranslation(location.id, fallback: fallback); } void unloadPacks(Iterable keys) { diff --git a/app/lib/main.dart b/app/lib/main.dart index 9679c9c4..aa900b3e 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -73,9 +73,10 @@ Future main(List args) async { const kUnsupportedLanguages = []; -List getLocales() => List.from( - AppLocalizations.supportedLocales, -).where((l) => !kUnsupportedLanguages.contains(l.toString())).toList(); +List getLocales() => + List.from(AppLocalizations.supportedLocales) + .where((l) => !kUnsupportedLanguages.contains(l.toString())) + .toList(); class SetonixApp extends StatelessWidget { SetonixApp({super.key}); diff --git a/app/lib/pages/editor/textures.dart b/app/lib/pages/editor/textures.dart index b9e50cae..7bd6ddb0 100644 --- a/app/lib/pages/editor/textures.dart +++ b/app/lib/pages/editor/textures.dart @@ -35,9 +35,8 @@ class TexturesEditorPage extends StatelessWidget { ), floatingActionButton: FloatingActionButton.extended( onPressed: () async { - final data = await FilePicker.pickFiles( - type: FileType.image, - ).then((result) => result.firstOrNull); + final data = await FilePicker.pickFiles(type: FileType.image) + .then((result) => result.firstOrNull); if (data == null) return; final bytes = await data.readAsBytes(); if (!context.mounted) return; diff --git a/app/lib/pages/game/auth.dart b/app/lib/pages/game/auth.dart index ca02c6b5..6695743c 100644 --- a/app/lib/pages/game/auth.dart +++ b/app/lib/pages/game/auth.dart @@ -93,9 +93,8 @@ class _AuthGameViewState extends State { if (authRequest.isRequired) Text( AppLocalizations.of(context).authenticateRequired, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Colors.red), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: Colors.red), ), const SizedBox(height: 16.0), Flexible( diff --git a/app/lib/pages/game/chat.dart b/app/lib/pages/game/chat.dart index 0e050928..758733ee 100644 --- a/app/lib/pages/game/chat.dart +++ b/app/lib/pages/game/chat.dart @@ -79,16 +79,14 @@ class _GameChatDrawerState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - AppLocalizations.of( - context, - ).defaultPlayerName(message.author), + AppLocalizations.of(context) + .defaultPlayerName(message.author), style: Theme.of(context).textTheme.bodyMedium, ), Text( DateFormat.Hms( - Localizations.localeOf( - context, - ).languageCode, + Localizations.localeOf(context) + .languageCode, ).format(message.timestamp), style: Theme.of(context).textTheme.bodySmall, ), diff --git a/app/lib/pages/game/dialog.dart b/app/lib/pages/game/dialog.dart index 40182122..5c30c3e8 100644 --- a/app/lib/pages/game/dialog.dart +++ b/app/lib/pages/game/dialog.dart @@ -157,12 +157,11 @@ class _GameDialogContentState extends State<_GameDialogContent> { const SizedBox(width: 12), Expanded( child: Text( - AppLocalizations.of( - context, - ).thirdPartyContent, - style: Theme.of( - context, - ).textTheme.bodyMedium, + AppLocalizations.of(context) + .thirdPartyContent, + style: Theme.of(context) + .textTheme + .bodyMedium, ), ), ], diff --git a/app/lib/pages/game/drawer.dart b/app/lib/pages/game/drawer.dart index e60fc428..bfba1e95 100644 --- a/app/lib/pages/game/drawer.dart +++ b/app/lib/pages/game/drawer.dart @@ -614,12 +614,10 @@ class GameDrawer extends StatelessWidget { ), content: TextFormField( decoration: InputDecoration( - labelText: AppLocalizations.of( - context, - ).name, - hintText: AppLocalizations.of( - context, - ).enterName, + labelText: AppLocalizations.of(context) + .name, + hintText: AppLocalizations.of(context) + .enterName, filled: true, ), initialValue: name, diff --git a/app/lib/pages/game/multiplayer/start.dart b/app/lib/pages/game/multiplayer/start.dart index 9d2fb34e..6c9c78b9 100644 --- a/app/lib/pages/game/multiplayer/start.dart +++ b/app/lib/pages/game/multiplayer/start.dart @@ -110,15 +110,13 @@ class _StartMultiplayerDialogState extends State keyboardType: TextInputType.number, validator: (value) { if (value?.isEmpty ?? true) { - return LeapLocalizations.of( - context, - ).shouldNotEmpty; + return LeapLocalizations.of(context) + .shouldNotEmpty; } final number = int.tryParse(value!); if (number == null) { - return AppLocalizations.of( - context, - ).shouldANumber; + return AppLocalizations.of(context) + .shouldANumber; } return null; }, diff --git a/app/lib/pages/game/multiplayer/view.dart b/app/lib/pages/game/multiplayer/view.dart index ad136159..5c86d566 100644 --- a/app/lib/pages/game/multiplayer/view.dart +++ b/app/lib/pages/game/multiplayer/view.dart @@ -378,9 +378,8 @@ class ViewMultiplayerDialog extends StatelessWidget { return ListTile( title: Text( player.name ?? - AppLocalizations.of( - context, - ).defaultUserName(player.id), + AppLocalizations.of(context) + .defaultUserName(player.id), ), subtitle: Text( [ @@ -396,9 +395,8 @@ class ViewMultiplayerDialog extends StatelessWidget { children: [ if (canRename) IconButton( - tooltip: AppLocalizations.of( - context, - ).rename, + tooltip: AppLocalizations.of(context) + .rename, icon: const PhosphorIcon( PhosphorIconsLight.pencilSimple, ), @@ -408,9 +406,8 @@ class ViewMultiplayerDialog extends StatelessWidget { if (hasModerationActions) MenuAnchor( builder: defaultMenuButton( - tooltip: MaterialLocalizations.of( - context, - ).showMenuTooltip, + tooltip: MaterialLocalizations.of(context) + .showMenuTooltip, ), menuChildren: [ if (canKick) @@ -452,9 +449,8 @@ class ViewMultiplayerDialog extends StatelessWidget { gameRoles, ), child: Text( - AppLocalizations.of( - context, - ).gameRoles, + AppLocalizations.of(context) + .gameRoles, ), ), if (canManageRoles) @@ -523,9 +519,8 @@ class ViewMultiplayerDialog extends StatelessWidget { }) .toList(growable: false), child: Text( - AppLocalizations.of( - context, - ).serverRoles, + AppLocalizations.of(context) + .serverRoles, ), ), ], diff --git a/app/lib/pages/game/notes.dart b/app/lib/pages/game/notes.dart index 5fc857a6..f59860a2 100644 --- a/app/lib/pages/game/notes.dart +++ b/app/lib/pages/game/notes.dart @@ -71,9 +71,8 @@ class _GameNotesDrawerState extends State { AppLocalizations.of(context).deleteNote, ), content: Text( - AppLocalizations.of( - context, - ).deleteNoteMessage(note), + AppLocalizations.of(context) + .deleteNoteMessage(note), ), actions: [ TextButton( diff --git a/app/lib/pages/game/page.dart b/app/lib/pages/game/page.dart index 30bcdf85..7a4f6874 100644 --- a/app/lib/pages/game/page.dart +++ b/app/lib/pages/game/page.dart @@ -350,18 +350,17 @@ class _GamePageState extends State SnackBar( width: 300, behavior: SnackBarBehavior.floating, - backgroundColor: Theme.of( - context, - ).colorScheme.surfaceContainer, + backgroundColor: Theme.of(context) + .colorScheme + .surfaceContainer, content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( AppLocalizations.of(context).newMessage( - AppLocalizations.of( - context, - ).defaultPlayerName(message.author), + AppLocalizations.of(context) + .defaultPlayerName(message.author), ), style: Theme.of(context).textTheme.bodyMedium, ), @@ -401,12 +400,11 @@ class _GamePageState extends State CircularProgressIndicator(), const SizedBox(height: 16), Text( - AppLocalizations.of( - context, - ).configuringGame, - style: Theme.of( - context, - ).textTheme.bodyMedium, + AppLocalizations.of(context) + .configuringGame, + style: Theme.of(context) + .textTheme + .bodyMedium, ), ], ) @@ -436,16 +434,16 @@ class _GamePageState extends State ), child: DecoratedBox( decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHigh, + color: Theme.of(context) + .colorScheme + .surfaceContainerHigh, borderRadius: BorderRadius.circular( 8, ), border: Border.all( - color: Theme.of( - context, - ).colorScheme.outlineVariant, + color: Theme.of(context) + .colorScheme + .outlineVariant, ), ), child: Padding( diff --git a/app/lib/pages/home/connect.dart b/app/lib/pages/home/connect.dart index 069c0204..69246294 100644 --- a/app/lib/pages/home/connect.dart +++ b/app/lib/pages/home/connect.dart @@ -404,9 +404,8 @@ class _ServersViewState extends State { AppLocalizations.of(context).deleteServer, ), content: Text( - AppLocalizations.of( - context, - ).deleteServerMessage, + AppLocalizations.of(context) + .deleteServerMessage, ), actions: [ TextButton( @@ -452,9 +451,8 @@ class _ServersViewState extends State { ? _FriendlyInfoState( icon: PhosphorIconsLight.plugsConnected, title: AppLocalizations.of(context).noServers, - message: AppLocalizations.of( - context, - ).noServersDescription, + message: AppLocalizations.of(context) + .noServersDescription, ) : ListView.builder( itemCount: servers.length, @@ -597,12 +595,10 @@ class _ServersViewState extends State { child: property == null ? _FriendlyInfoState( icon: PhosphorIconsLight.handTap, - title: AppLocalizations.of( - context, - ).selectServer, - message: AppLocalizations.of( - context, - ).selectServerDescription, + title: AppLocalizations.of(context) + .selectServer, + message: AppLocalizations.of(context) + .selectServerDescription, ) : Column( crossAxisAlignment: @@ -612,9 +608,9 @@ class _ServersViewState extends State { padding: const EdgeInsets.all(8.0), child: DefaultTextStyle( style: - Theme.of( - context, - ).textTheme.titleLarge ?? + Theme.of(context) + .textTheme + .titleLarge ?? const TextStyle(), child: Row( crossAxisAlignment: diff --git a/app/lib/pages/home/create.dart b/app/lib/pages/home/create.dart index 6cc723d3..6131f77a 100644 --- a/app/lib/pages/home/create.dart +++ b/app/lib/pages/home/create.dart @@ -65,9 +65,9 @@ class _CreateDialogState extends State } void _reloadTemplates() => setState( - () => _templatesStream = ValueConnectableStream( - _loadTemplates(), - ).autoConnect(), + () => + _templatesStream = ValueConnectableStream(_loadTemplates()) + .autoConnect(), ); Stream>> _loadTemplates() async* { diff --git a/app/lib/pages/home/page.dart b/app/lib/pages/home/page.dart index 48fcac7f..43b5b1df 100644 --- a/app/lib/pages/home/page.dart +++ b/app/lib/pages/home/page.dart @@ -91,9 +91,8 @@ class _HomePageState extends State { title: Text(title), leading: isMobile ? IconButton( - tooltip: MaterialLocalizations.of( - context, - ).openAppDrawerTooltip, + tooltip: MaterialLocalizations.of(context) + .openAppDrawerTooltip, icon: const PhosphorIcon(PhosphorIconsLight.list), onPressed: () => _scaffoldKey.currentState?.openDrawer(), ) diff --git a/app/lib/pages/home/play.dart b/app/lib/pages/home/play.dart index ff4c5838..77f6c54e 100644 --- a/app/lib/pages/home/play.dart +++ b/app/lib/pages/home/play.dart @@ -214,9 +214,8 @@ class _ModePickerView extends StatelessWidget { padding: const EdgeInsets.fromLTRB(4, 4, 4, 20), child: _GamesHeader( title: AppLocalizations.of(context).games, - subtitle: AppLocalizations.of( - context, - ).homeSingleplayerDescription, + subtitle: AppLocalizations.of(context) + .homeSingleplayerDescription, actions: [ OutlinedButton.icon( onPressed: onCreate, diff --git a/app/lib/pages/packs/dialog.dart b/app/lib/pages/packs/dialog.dart index 5c7831b1..882e2799 100644 --- a/app/lib/pages/packs/dialog.dart +++ b/app/lib/pages/packs/dialog.dart @@ -49,13 +49,12 @@ class PacksView extends StatefulWidget { State createState() => _PacksViewState(); } -typedef SelectFunction = - Future Function( - Widget title, - List details, - List actions, - String id, - ); +typedef SelectFunction = Future Function( + Widget title, + List details, + List actions, + String id, +); class _PacksViewState extends State with TickerProviderStateMixin { // It needs to be here to be able to reload it when clicking on import diff --git a/app/lib/pages/packs/editor.dart b/app/lib/pages/packs/editor.dart index af6591a2..8df31d30 100644 --- a/app/lib/pages/packs/editor.dart +++ b/app/lib/pages/packs/editor.dart @@ -91,9 +91,8 @@ class _EditorPacksViewState extends State<_EditorPacksView> { AppLocalizations.of(context).removePack, ), content: Text( - AppLocalizations.of( - context, - ).removePackMessage(file.identifier), + AppLocalizations.of(context) + .removePackMessage(file.identifier), ), actions: [ TextButton( diff --git a/app/lib/pages/settings/data.dart b/app/lib/pages/settings/data.dart index 53e194b0..ac0429e2 100644 --- a/app/lib/pages/settings/data.dart +++ b/app/lib/pages/settings/data.dart @@ -62,9 +62,8 @@ class DataSettingsPage extends StatelessWidget { icon: const PhosphorIcon( PhosphorIconsLight.clockClockwise, ), - tooltip: AppLocalizations.of( - context, - ).defaultPath, + tooltip: AppLocalizations.of(context) + .defaultPath, onPressed: () => changePath(''), ) : null, diff --git a/app/lib/pages/settings/general.dart b/app/lib/pages/settings/general.dart index d4bd82b1..4c65f03e 100644 --- a/app/lib/pages/settings/general.dart +++ b/app/lib/pages/settings/general.dart @@ -108,9 +108,8 @@ class _GeneralSettingsPageState extends State { AppLocalizations.of(context).checkForUpdates, ), subtitle: Text( - AppLocalizations.of( - context, - ).checkForUpdatesWarning, + AppLocalizations.of(context) + .checkForUpdatesWarning, ), onTap: loadMeta, ); @@ -155,9 +154,8 @@ class _GeneralSettingsPageState extends State { if (isStable) ...[ ListTile( title: Text( - AppLocalizations.of( - context, - ).usingLatestStable, + AppLocalizations.of(context) + .usingLatestStable, ), ), ] else if (isNightly || @@ -165,9 +163,8 @@ class _GeneralSettingsPageState extends State { isMain) ...[ ListTile( title: Text( - AppLocalizations.of( - context, - ).usingLatestNightly, + AppLocalizations.of(context) + .usingLatestNightly, ), ), ] else if (isError) ...[ @@ -179,9 +176,8 @@ class _GeneralSettingsPageState extends State { ] else if (isUpdateAvailable) ListTile( title: Text( - AppLocalizations.of( - context, - ).updateAvailable, + AppLocalizations.of(context) + .updateAvailable, ), subtitle: Text( AppLocalizations.of(context).updateNow, diff --git a/app/lib/pages/settings/personalization.dart b/app/lib/pages/settings/personalization.dart index 722198f5..f386465b 100644 --- a/app/lib/pages/settings/personalization.dart +++ b/app/lib/pages/settings/personalization.dart @@ -83,9 +83,7 @@ class PersonalizationSettingsPage extends StatelessWidget { ), title: Text(AppLocalizations.of(context).locale), subtitle: Text( - AppLocalizations.of( - context, - ).comingSoon /*_getLocaleName(context, state.localeTag)*/, + AppLocalizations.of(context).comingSoon /*_getLocaleName(context, state.localeTag)*/, ), onTap: null /*() => _openLocaleModal(context)*/, ), diff --git a/app/lib/pages/settings/servers.dart b/app/lib/pages/settings/servers.dart index db9c2874..16f55c9c 100644 --- a/app/lib/pages/settings/servers.dart +++ b/app/lib/pages/settings/servers.dart @@ -31,9 +31,8 @@ class ServersSettingsPage extends StatelessWidget { AppLocalizations.of(context).loadDefaultServerList, ), content: Text( - AppLocalizations.of( - context, - ).loadDefaultServerListDescription, + AppLocalizations.of(context) + .loadDefaultServerListDescription, ), actions: [ TextButton( diff --git a/app/lib/services/file_system.dart b/app/lib/services/file_system.dart index d06804b6..bb1ac04c 100644 --- a/app/lib/services/file_system.dart +++ b/app/lib/services/file_system.dart @@ -50,9 +50,8 @@ class SetonixFileSystem { static const kDatabaseVersion = 3; - SetonixFileSystem({SetonixFile? corePack}) - : _corePack = corePack, - packSystem = TypedKeyFileSystem.build( + SetonixFileSystem({this._corePack}) + : packSystem = TypedKeyFileSystem.build( FileSystemConfig( passwordStorage: SecureStoragePasswordStorage(), storeName: 'packs', @@ -277,9 +276,8 @@ class SetonixFileSystem { Future> getAccounts([List? names]) async { await privateKeySystem.initialize(); names ??= await privateKeySystem.getKeys(); - return Future.wait( - names.map((name) => getAccount(name)), - ).then((accounts) => accounts.nonNulls.toList()); + return Future.wait(names.map((name) => getAccount(name))) + .then((accounts) => accounts.nonNulls.toList()); } Future exportAccounts([ diff --git a/app/pubspec.lock b/app/pubspec.lock index d163450e..66a913e4 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -1547,5 +1547,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.12.0 <4.0.0" - flutter: ">=3.44.9" + dart: ">=3.13.0 <4.0.0" + flutter: "3.47.0" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 6beccda5..2bb54792 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -15,8 +15,8 @@ version: 0.8.2+15 publish_to: none environment: - sdk: ">=3.8.0 <4.0.0" - flutter: 3.44.9 + sdk: ">=3.13.0 <4.0.0" + flutter: 3.47.0 dependencies: flutter: diff --git a/plugin/analysis_options.yaml b/plugin/analysis_options.yaml index eabb6ff2..99d6f3ed 100644 --- a/plugin/analysis_options.yaml +++ b/plugin/analysis_options.yaml @@ -1,6 +1,13 @@ analyzer: exclude: - rust/target/**.dart # contains dumped debug info, instead of normal code + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** linter: rules: diff --git a/plugin/lib/src/events/system.dart b/plugin/lib/src/events/system.dart index a968f7cb..b06da8f9 100644 --- a/plugin/lib/src/events/system.dart +++ b/plugin/lib/src/events/system.dart @@ -6,8 +6,9 @@ import 'package:setonix_api/setonix_api.dart'; import 'package:setonix_plugin/events.dart'; import 'package:setonix_plugin/src/events/model.dart'; -typedef EventHandler = - Future Function(Event event); +typedef EventHandler = Future Function( + Event event, +); final class EventSubscription { final EventHandler _handler; diff --git a/plugin/lib/src/plugin.dart b/plugin/lib/src/plugin.dart index 0a9543b1..1c10ad0e 100644 --- a/plugin/lib/src/plugin.dart +++ b/plugin/lib/src/plugin.dart @@ -8,11 +8,10 @@ import 'package:setonix_plugin/setonix_plugin.dart'; import 'package:setonix_plugin/src/rust/frb_generated.dart'; typedef PluginProcessCallback = void Function(String, WorldEvent, [bool force]); -typedef PluginSendEventCallback = - void Function( - NetworkerPacket packet, - String? worldName, - ); +typedef PluginSendEventCallback = void Function( + NetworkerPacket packet, + String? worldName, +); mixin ServerInterface { Future process( diff --git a/plugin/lib/src/rust/api/luau.dart b/plugin/lib/src/rust/api/luau.dart index 63eb578d..90b7d683 100644 --- a/plugin/lib/src/rust/api/luau.dart +++ b/plugin/lib/src/rust/api/luau.dart @@ -4,7 +4,9 @@ // ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import import '../frb_generated.dart'; + import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + import 'plugin.dart'; // These functions are ignored because they are not marked as `pub`: `construct_globals`, `construct_high_level_api`, `construct_on_print`, `construct_raw_api` diff --git a/plugin/lib/src/rust/api/plugin.dart b/plugin/lib/src/rust/api/plugin.dart index 2d21dad3..d4107f6c 100644 --- a/plugin/lib/src/rust/api/plugin.dart +++ b/plugin/lib/src/rust/api/plugin.dart @@ -4,6 +4,7 @@ // ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import import '../frb_generated.dart'; + import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; // These functions are ignored because they are not marked as `pub`: `build`, `get`, `new`, `schedule_event` diff --git a/plugin/lib/src/rust/api/simple.dart b/plugin/lib/src/rust/api/simple.dart index 3d37fab8..284b0ab6 100644 --- a/plugin/lib/src/rust/api/simple.dart +++ b/plugin/lib/src/rust/api/simple.dart @@ -4,6 +4,7 @@ // ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import import '../frb_generated.dart'; + import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; Future simpleAdderTwinNormal({required int a, required int b}) => diff --git a/plugin/lib/src/rust/frb_generated.dart b/plugin/lib/src/rust/frb_generated.dart index a1a4b5f2..3a79f377 100644 --- a/plugin/lib/src/rust/frb_generated.dart +++ b/plugin/lib/src/rust/frb_generated.dart @@ -6,11 +6,14 @@ import 'api/luau.dart'; import 'api/plugin.dart'; import 'api/simple.dart'; + import 'dart:async'; import 'dart:convert'; + import 'frb_generated.dart'; import 'frb_generated.io.dart' if (dart.library.js_interop) 'frb_generated.web.dart'; + import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; /// Main entrypoint of the Rust API diff --git a/plugin/lib/src/rust/frb_generated.io.dart b/plugin/lib/src/rust/frb_generated.io.dart index 58374e33..a2ea07f9 100644 --- a/plugin/lib/src/rust/frb_generated.io.dart +++ b/plugin/lib/src/rust/frb_generated.io.dart @@ -6,10 +6,13 @@ import 'api/luau.dart'; import 'api/plugin.dart'; import 'api/simple.dart'; + import 'dart:async'; import 'dart:convert'; import 'dart:ffi' as ffi; + import 'frb_generated.dart'; + import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart'; abstract class RustLibApiImplPlatform extends BaseApiImpl { diff --git a/plugin/lib/src/rust/frb_generated.web.dart b/plugin/lib/src/rust/frb_generated.web.dart index 689ae8c9..b2831d3e 100644 --- a/plugin/lib/src/rust/frb_generated.web.dart +++ b/plugin/lib/src/rust/frb_generated.web.dart @@ -9,9 +9,12 @@ import 'api/luau.dart'; import 'api/plugin.dart'; import 'api/simple.dart'; + import 'dart:async'; import 'dart:convert'; + import 'frb_generated.dart'; + import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart'; abstract class RustLibApiImplPlatform extends BaseApiImpl { diff --git a/plugin/lib/src/rust/lib.dart b/plugin/lib/src/rust/lib.dart index 039f624a..7e452dc5 100644 --- a/plugin/lib/src/rust/lib.dart +++ b/plugin/lib/src/rust/lib.dart @@ -4,6 +4,7 @@ // ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import import 'frb_generated.dart'; + import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; // Rust type: RustOpaqueMoi> diff --git a/plugin/pubspec.lock b/plugin/pubspec.lock index db9b052e..b5e09a06 100644 --- a/plugin/pubspec.lock +++ b/plugin/pubspec.lock @@ -596,4 +596,4 @@ packages: source: hosted version: "2.2.4" sdks: - dart: ">=3.11.0 <4.0.0" + dart: ">=3.13.0 <4.0.0" diff --git a/plugin/pubspec.yaml b/plugin/pubspec.yaml index 0e3c139e..e1401b3c 100644 --- a/plugin/pubspec.yaml +++ b/plugin/pubspec.yaml @@ -3,7 +3,7 @@ description: Plugin interface for setonix version: 1.0.0 publish_to: none environment: - sdk: '>=3.8.0 <4.0.0' + sdk: '>=3.13.0 <4.0.0' dependencies: flutter_rust_bridge: 2.13.0-beta.6 collection: ^1.16.0 diff --git a/plugin/rust/src/frb_generated.rs b/plugin/rust/src/frb_generated.rs index 778aa581..37d3de8e 100644 --- a/plugin/rust/src/frb_generated.rs +++ b/plugin/rust/src/frb_generated.rs @@ -31,7 +31,7 @@ use crate::api::luau::*; use crate::api::plugin::RustPlugin; use crate::api::plugin::*; use flutter_rust_bridge::for_generated::byteorder::{NativeEndian, ReadBytesExt, WriteBytesExt}; -use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; +use flutter_rust_bridge::for_generated::{Lifetimeable, Lockable, transform_result_dco}; use flutter_rust_bridge::{Handler, IntoIntoDart}; // Section: boilerplate @@ -1139,7 +1139,7 @@ mod io { use flutter_rust_bridge::for_generated::byteorder::{ NativeEndian, ReadBytesExt, WriteBytesExt, }; - use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; + use flutter_rust_bridge::for_generated::{Lifetimeable, Lockable, transform_result_dco}; use flutter_rust_bridge::{Handler, IntoIntoDart}; // Section: boilerplate @@ -1194,7 +1194,7 @@ mod web { }; use flutter_rust_bridge::for_generated::wasm_bindgen; use flutter_rust_bridge::for_generated::wasm_bindgen::prelude::*; - use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; + use flutter_rust_bridge::for_generated::{Lifetimeable, Lockable, transform_result_dco}; use flutter_rust_bridge::{Handler, IntoIntoDart}; // Section: boilerplate diff --git a/server/analysis_options.yaml b/server/analysis_options.yaml index dee8927a..6c4f4c8d 100644 --- a/server/analysis_options.yaml +++ b/server/analysis_options.yaml @@ -11,6 +11,15 @@ # (the recommended set includes the core lints). # The core lints are also what is used by pub.dev for scoring packages. +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:lints/recommended.yaml # Uncomment the following section to specify additional rules. diff --git a/server/lib/setonix_server.dart b/server/lib/setonix_server.dart index da5f62af..bae693f4 100644 --- a/server/lib/setonix_server.dart +++ b/server/lib/setonix_server.dart @@ -3,6 +3,7 @@ library; export 'package:setonix_api/setonix_api.dart'; export 'package:setonix_plugin/setonix_plugin.dart'; + export 'src/asset.dart'; export 'src/authorization.dart'; export 'src/bloc.dart'; @@ -23,4 +24,5 @@ export 'src/programs/say.dart'; export 'src/programs/stop.dart'; export 'src/programs/whitelist.dart'; export 'src/programs/worlds.dart'; + export 'package:networker/networker.dart'; diff --git a/server/lib/src/main.dart b/server/lib/src/main.dart index 05b7ea80..36d7336a 100644 --- a/server/lib/src/main.dart +++ b/server/lib/src/main.dart @@ -46,8 +46,7 @@ ArgParser buildParser() { ..addOption( 'thumbnail', abbr: 't', - help: - 'Path to an image file for the server thumbnail. Will be displayed in the server list.', + help: 'Path to an image file for the server thumbnail. Will be displayed in the server list.', ) ..addOption( 'autosave', diff --git a/server/lib/src/server.dart b/server/lib/src/server.dart index 99101e16..9a6262b6 100644 --- a/server/lib/src/server.dart +++ b/server/lib/src/server.dart @@ -342,12 +342,10 @@ final class SetonixServer { } SecurityContext? securityContext; try { - final privateKey = await File( - p.join(rootDirectory, 'certs/server.key'), - ).readAsBytes(); - final certificate = await File( - p.join(rootDirectory, 'certs/server.crt'), - ).readAsBytes(); + final privateKey = await File(p.join(rootDirectory, 'certs/server.key')) + .readAsBytes(); + final certificate = await File(p.join(rootDirectory, 'certs/server.crt')) + .readAsBytes(); securityContext = SecurityContext() ..usePrivateKeyBytes(privateKey) ..useCertificateChainBytes(certificate); @@ -434,9 +432,8 @@ final class SetonixServer { String? worldName, }) { if (_closing) return; - getWorld( - worldName ?? defaultWorldName, - )?.onClientEvent(packet, force: force); + getWorld(worldName ?? defaultWorldName) + ?.onClientEvent(packet, force: force); } static R _runStaticLogZone(Consoler consoler, R Function() body) => diff --git a/server/pubspec.lock b/server/pubspec.lock index 85f587cd..925040bf 100644 --- a/server/pubspec.lock +++ b/server/pubspec.lock @@ -725,4 +725,4 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.11.0 <4.0.0" + dart: ">=3.13.0 <4.0.0" diff --git a/server/pubspec.yaml b/server/pubspec.yaml index 5ff708ce..d5eb0a62 100644 --- a/server/pubspec.yaml +++ b/server/pubspec.yaml @@ -4,7 +4,7 @@ version: 0.8.2 publish_to: none environment: - sdk: ">=3.8.0 <4.0.0" + sdk: ">=3.13.0 <4.0.0" dependencies: args: ^2.4.2 diff --git a/tools/analysis_options.yaml b/tools/analysis_options.yaml index 28875d1c..4d64e826 100644 --- a/tools/analysis_options.yaml +++ b/tools/analysis_options.yaml @@ -7,6 +7,15 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:lints/recommended.yaml linter: diff --git a/tools/pubspec.lock b/tools/pubspec.lock index fb489c04..65a7bafe 100644 --- a/tools/pubspec.lock +++ b/tools/pubspec.lock @@ -251,4 +251,4 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0 <4.0.0" + dart: ">=3.13.0 <4.0.0" diff --git a/tools/pubspec.yaml b/tools/pubspec.yaml index 6ee6fe86..8171f2f9 100644 --- a/tools/pubspec.yaml +++ b/tools/pubspec.yaml @@ -1,6 +1,6 @@ name: setonix_tools environment: - sdk: ">=3.8.0 <4.0.0" + sdk: ">=3.13.0 <4.0.0" dependencies: archive: ^4.0.1 args: ^2.5.0